feat: improve oauth (#14061)

fix #13730
fix #12901
fix #14025
This commit is contained in:
DarkSky
2025-12-08 10:44:41 +08:00
committed by GitHub
parent 6e6b85098e
commit f29e47e9d2
14 changed files with 495 additions and 224 deletions
@@ -6,10 +6,11 @@ import { z } from 'zod';
import {
InternalServerError,
InvalidOauthCallbackCode,
InvalidAuthState,
URLHelper,
} from '../../../base';
import { OAuthProviderName } from '../config';
import type { OAuthState } from '../types';
import { OAuthProvider, Tokens } from './def';
interface AuthTokenResponse {
@@ -102,54 +103,39 @@ export class AppleOAuthProvider extends OAuthProvider {
})}`;
}
async getToken(code: string) {
const response = await fetch('https://appleid.apple.com/auth/token', {
method: 'POST',
body: this.url.stringify({
async getToken(code: string, _state: OAuthState) {
const appleToken = await this.postFormJson<AuthTokenResponse>(
'https://appleid.apple.com/auth/token',
this.url.stringify({
code,
client_id: this.config.clientId,
client_secret: this.clientSecret,
redirect_uri: this.url.link('/api/oauth/callback'),
grant_type: 'authorization_code',
}),
headers: {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
});
})
);
if (response.ok) {
const appleToken = (await response.json()) as AuthTokenResponse;
return {
accessToken: appleToken.access_token,
refreshToken: appleToken.refresh_token,
expiresAt: new Date(Date.now() + appleToken.expires_in * 1000),
idToken: appleToken.id_token,
};
} else {
const body = await response.text();
if (response.status < 500) {
throw new InvalidOauthCallbackCode({ status: response.status, body });
}
throw new Error(
`Server responded with non-success status ${response.status}, body: ${body}`
);
}
return {
accessToken: appleToken.access_token,
refreshToken: appleToken.refresh_token,
expiresAt: new Date(Date.now() + appleToken.expires_in * 1000),
idToken: appleToken.id_token,
};
}
async getUser(
tokens: Tokens & { idToken: string },
state: { clientNonce: string }
) {
const keysReq = await fetch('https://appleid.apple.com/auth/keys', {
method: 'GET',
});
const { keys } = (await keysReq.json()) as { keys: JsonWebKey[] };
async getUser(tokens: Tokens, state: OAuthState) {
if (!tokens.idToken) {
throw new InvalidAuthState();
}
const { keys } = await this.fetchJson<{ keys: JsonWebKey[] }>(
'https://appleid.apple.com/auth/keys',
{ method: 'GET' },
{ treatServerErrorAsInvalid: true }
);
const payload = await new Promise<JwtPayload>((resolve, reject) => {
jwt.verify(
tokens.idToken,
tokens.idToken!,
(header, callback) => {
const key = keys.find(key => key.kid === header.kid);
if (!key) {
@@ -1,8 +1,14 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { Config, OnEvent } from '../../../base';
import {
Config,
InvalidOauthCallbackCode,
InvalidOauthResponse,
OnEvent,
} from '../../../base';
import { OAuthProviderName } from '../config';
import { OAuthProviderFactory } from '../factory';
import type { OAuthState } from '../types';
export interface OAuthAccount {
id: string;
@@ -16,6 +22,8 @@ export interface Tokens {
scope?: string;
refreshToken?: string;
expiresAt?: Date;
idToken?: string;
tokenType?: string;
}
export interface AuthOptions {
@@ -29,8 +37,8 @@ export interface AuthOptions {
export abstract class OAuthProvider {
abstract provider: OAuthProviderName;
abstract getAuthUrl(state: string, clientNonce?: string): string;
abstract getToken(code: string): Promise<Tokens>;
abstract getUser(tokens: Tokens, state: any): Promise<OAuthAccount>;
abstract getToken(code: string, state: OAuthState): Promise<Tokens>;
abstract getUser(tokens: Tokens, state: OAuthState): Promise<OAuthAccount>;
protected readonly logger = new Logger(this.constructor.name);
@Inject() private readonly factory!: OAuthProviderFactory;
@@ -65,4 +73,63 @@ export abstract class OAuthProvider {
this.factory.unregister(this);
}
}
get requiresPkce() {
return false;
}
protected async fetchJson<T>(
url: string,
init?: RequestInit,
options?: { treatServerErrorAsInvalid?: boolean }
) {
const response = await fetch(url, {
headers: { Accept: 'application/json', ...init?.headers },
...init,
});
const body = await response.text();
if (!response.ok) {
if (response.status < 500 || options?.treatServerErrorAsInvalid) {
throw new InvalidOauthCallbackCode({ status: response.status, body });
}
throw new Error(
`Server responded with non-success status ${response.status}, body: ${body}`
);
}
if (!body) {
return {} as T;
}
try {
return JSON.parse(body) as T;
} catch {
throw new InvalidOauthResponse({
reason: `Unable to parse JSON response from ${url}`,
});
}
}
protected postFormJson<T>(
url: string,
body: string,
options?: {
headers?: Record<string, string>;
treatServerErrorAsInvalid?: boolean;
}
) {
return this.fetchJson<T>(
url,
{
method: 'POST',
body,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...options?.headers,
},
},
options
);
}
}
@@ -1,8 +1,9 @@
import { Injectable } from '@nestjs/common';
import { InvalidOauthCallbackCode, URLHelper } from '../../../base';
import { URLHelper } from '../../../base';
import { OAuthProviderName } from '../config';
import { OAuthProvider, Tokens } from './def';
import type { OAuthState } from '../types';
import { OAuthAccount, OAuthProvider, Tokens } from './def';
interface AuthTokenResponse {
access_token: string;
@@ -35,64 +36,36 @@ export class GithubOAuthProvider extends OAuthProvider {
})}`;
}
async getToken(code: string) {
const response = await fetch(
async getToken(code: string, _state: OAuthState): Promise<Tokens> {
const ghToken = await this.postFormJson<AuthTokenResponse>(
'https://github.com/login/oauth/access_token',
{
method: 'POST',
body: this.url.stringify({
code,
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
redirect_uri: this.url.link('/oauth/callback'),
}),
headers: {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
}
this.url.stringify({
code,
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
redirect_uri: this.url.link('/oauth/callback'),
})
);
if (response.ok) {
const ghToken = (await response.json()) as AuthTokenResponse;
return {
accessToken: ghToken.access_token,
scope: ghToken.scope,
};
} else {
const body = await response.text();
if (response.status < 500) {
throw new InvalidOauthCallbackCode({ status: response.status, body });
}
throw new Error(
`Server responded with non-success status ${response.status}, body: ${body}`
);
}
return {
accessToken: ghToken.access_token,
scope: ghToken.scope,
};
}
async getUser(tokens: Tokens) {
const response = await fetch('https://api.github.com/user', {
async getUser(tokens: Tokens, _state: OAuthState): Promise<OAuthAccount> {
const user = await this.fetchJson<UserInfo>('https://api.github.com/user', {
method: 'GET',
headers: {
Authorization: `Bearer ${tokens.accessToken}`,
},
});
if (response.ok) {
const user = (await response.json()) as UserInfo;
return {
id: user.login,
avatarUrl: user.avatar_url,
email: user.email,
};
} else {
throw new Error(
`Server responded with non-success code ${
response.status
} ${await response.text()}`
);
}
return {
id: user.login,
avatarUrl: user.avatar_url,
email: user.email,
name: user.name,
};
}
}
@@ -1,8 +1,9 @@
import { Injectable } from '@nestjs/common';
import { InvalidOauthCallbackCode, URLHelper } from '../../../base';
import { URLHelper } from '../../../base';
import { OAuthProviderName } from '../config';
import { OAuthProvider, Tokens } from './def';
import type { OAuthState } from '../types';
import { OAuthAccount, OAuthProvider, Tokens } from './def';
interface GoogleOAuthTokenResponse {
access_token: string;
@@ -40,44 +41,28 @@ export class GoogleOAuthProvider extends OAuthProvider {
})}`;
}
async getToken(code: string) {
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
body: this.url.stringify({
async getToken(code: string, _state: OAuthState): Promise<Tokens> {
const gToken = await this.postFormJson<GoogleOAuthTokenResponse>(
'https://oauth2.googleapis.com/token',
this.url.stringify({
code,
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
redirect_uri: this.url.link('/oauth/callback'),
grant_type: 'authorization_code',
}),
headers: {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
});
})
);
if (response.ok) {
const ghToken = (await response.json()) as GoogleOAuthTokenResponse;
return {
accessToken: ghToken.access_token,
refreshToken: ghToken.refresh_token,
expiresAt: new Date(Date.now() + ghToken.expires_in * 1000),
scope: ghToken.scope,
};
} else {
const body = await response.text();
if (response.status < 500) {
throw new InvalidOauthCallbackCode({ status: response.status, body });
}
throw new Error(
`Server responded with non-success status ${response.status}, body: ${body}`
);
}
return {
accessToken: gToken.access_token,
refreshToken: gToken.refresh_token,
expiresAt: new Date(Date.now() + gToken.expires_in * 1000),
scope: gToken.scope,
};
}
async getUser(tokens: Tokens) {
const response = await fetch(
async getUser(tokens: Tokens, _state: OAuthState): Promise<OAuthAccount> {
const user = await this.fetchJson<UserInfo>(
'https://www.googleapis.com/oauth2/v2/userinfo',
{
method: 'GET',
@@ -87,20 +72,11 @@ export class GoogleOAuthProvider extends OAuthProvider {
}
);
if (response.ok) {
const user = (await response.json()) as UserInfo;
return {
id: user.id,
avatarUrl: user.picture,
email: user.email,
};
} else {
throw new Error(
`Server responded with non-success code ${
response.status
} ${await response.text()}`
);
}
return {
id: user.id,
avatarUrl: user.picture,
email: user.email,
name: user.name,
};
}
}
@@ -1,21 +1,34 @@
import { Injectable } from '@nestjs/common';
import { createRemoteJWKSet, type JWTPayload, jwtVerify } from 'jose';
import { omit } from 'lodash-es';
import { z } from 'zod';
import {
InvalidOauthCallbackCode,
InvalidAuthState,
InvalidOauthResponse,
URLHelper,
} from '../../../base';
import { OAuthOIDCProviderConfig, OAuthProviderName } from '../config';
import type { OAuthState } from '../types';
import { OAuthAccount, OAuthProvider, Tokens } from './def';
const StatePayloadSchema = z.object({
state: z.string().optional(),
pkce: z
.object({
codeChallenge: z.string(),
codeChallengeMethod: z.string(),
})
.optional(),
});
const OIDCTokenSchema = z.object({
access_token: z.string(),
expires_in: z.number(),
refresh_token: z.string(),
expires_in: z.number().positive().optional(),
refresh_token: z.string().optional(),
scope: z.string().optional(),
token_type: z.string(),
id_token: z.string(),
});
const OIDCUserInfoSchema = z
@@ -23,7 +36,8 @@ const OIDCUserInfoSchema = z
sub: z.string(),
preferred_username: z.string().optional(),
email: z.string().email(),
name: z.string(),
name: z.string().optional(),
email_verified: z.boolean().optional(),
groups: z.array(z.string()).optional(),
})
.passthrough();
@@ -32,6 +46,8 @@ const OIDCConfigurationSchema = z.object({
authorization_endpoint: z.string().url(),
token_endpoint: z.string().url(),
userinfo_endpoint: z.string().url(),
issuer: z.string().url(),
jwks_uri: z.string().url(),
});
type OIDCConfiguration = z.infer<typeof OIDCConfigurationSchema>;
@@ -40,11 +56,16 @@ type OIDCConfiguration = z.infer<typeof OIDCConfigurationSchema>;
export class OIDCProvider extends OAuthProvider {
override provider = OAuthProviderName.OIDC;
#endpoints: OIDCConfiguration | null = null;
#jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
constructor(private readonly url: URLHelper) {
super();
}
override get requiresPkce() {
return true;
}
private get endpoints() {
if (!this.#endpoints) {
throw new Error('OIDC provider is not configured');
@@ -52,16 +73,30 @@ export class OIDCProvider extends OAuthProvider {
return this.#endpoints;
}
private get jwks() {
if (!this.#jwks) {
throw new Error('OIDC provider is not configured');
}
return this.#jwks;
}
override get configured() {
return this.#endpoints !== null;
return this.#endpoints !== null && this.#jwks !== null;
}
protected override setup() {
const validate = async () => {
this.#endpoints = null;
this.#jwks = null;
if (super.configured) {
const config = this.config as OAuthOIDCProviderConfig;
if (!config.issuer) {
this.logger.error('Missing OIDC issuer configuration');
super.setup();
return;
}
try {
const res = await fetch(
`${config.issuer}/.well-known/openid-configuration`,
@@ -72,7 +107,20 @@ export class OIDCProvider extends OAuthProvider {
);
if (res.ok) {
this.#endpoints = OIDCConfigurationSchema.parse(await res.json());
const configuration = OIDCConfigurationSchema.parse(
await res.json()
);
if (
this.normalizeIssuer(config.issuer) !==
this.normalizeIssuer(configuration.issuer)
) {
this.logger.error(
`OIDC issuer mismatch, expected ${config.issuer}, got ${configuration.issuer}`
);
} else {
this.#endpoints = configuration;
this.#jwks = createRemoteJWKSet(new URL(configuration.jwks_uri));
}
} else {
this.logger.error(`Invalid OIDC issuer ${config.issuer}`);
}
@@ -90,89 +138,240 @@ export class OIDCProvider extends OAuthProvider {
}
getAuthUrl(state: string): string {
return `${this.endpoints.authorization_endpoint}?${this.url.stringify({
const parsedState = this.parseStatePayload(state);
const nonce = parsedState?.state ?? state;
const pkce = parsedState?.pkce;
if (
this.requiresPkce &&
(!pkce?.codeChallenge || !pkce.codeChallengeMethod)
) {
throw new InvalidOauthResponse({
reason: 'Missing PKCE challenge for OIDC authorization request',
});
}
const query: JWTPayload = {
client_id: this.config.clientId,
redirect_uri: this.url.link('/oauth/callback'),
scope: this.config.args?.scope || 'openid profile email',
scope: this.resolveScope(this.config.args?.scope),
response_type: 'code',
...omit(this.config.args, 'claim_id', 'claim_email', 'claim_name'),
...omit(
this.config.args,
'claim_id',
'claim_email',
'claim_name',
'claim_email_verified'
),
state,
})}`;
nonce,
};
if (pkce) {
query.code_challenge = pkce.codeChallenge;
query.code_challenge_method = pkce.codeChallengeMethod;
}
return `${this.endpoints.authorization_endpoint}?${this.url.stringify(
query
)}`;
}
async getToken(code: string): Promise<Tokens> {
const res = await fetch(this.endpoints.token_endpoint, {
method: 'POST',
body: this.url.stringify({
async getToken(code: string, state: OAuthState): Promise<Tokens> {
if (this.requiresPkce && !state.pkce?.codeVerifier) {
throw new InvalidAuthState();
}
const data = await this.postFormJson<unknown>(
this.endpoints.token_endpoint,
this.url.stringify({
code,
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
redirect_uri: this.url.link('/oauth/callback'),
grant_type: 'authorization_code',
...(state.pkce?.codeVerifier
? { code_verifier: state.pkce.codeVerifier }
: {}),
}),
headers: {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
});
{ treatServerErrorAsInvalid: true }
);
if (res.ok) {
const data = await res.json();
const tokens = OIDCTokenSchema.parse(data);
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt: new Date(Date.now() + tokens.expires_in * 1000),
scope: tokens.scope,
};
const tokens = OIDCTokenSchema.parse(data);
if (!tokens.id_token) {
throw new InvalidOauthResponse({
reason: 'Missing id_token in OIDC token response',
});
}
throw new InvalidOauthCallbackCode({
status: res.status,
body: await res.text(),
});
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt: tokens.expires_in
? new Date(Date.now() + tokens.expires_in * 1000)
: undefined,
scope: tokens.scope,
idToken: tokens.id_token,
tokenType: tokens.token_type,
};
}
async getUser(tokens: Tokens): Promise<OAuthAccount> {
const res = await fetch(this.endpoints.userinfo_endpoint, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${tokens.accessToken}`,
},
});
private parseStatePayload(state: string) {
if (!state) {
return null;
}
if (res.ok) {
const body = await res.json();
const user = OIDCUserInfoSchema.parse(body);
try {
const stateObj = JSON.parse(state);
return StatePayloadSchema.parse(stateObj);
} catch {
return null;
}
}
const args = this.config.args ?? {};
private resolveScope(scope?: string) {
if (!scope) {
return 'openid profile email';
}
const claimsMap = {
id: args.claim_id || 'preferred_username',
email: args.claim_email || 'email',
name: args.claim_name || 'name',
};
const segments = scope.split(/\s+/).filter(Boolean);
if (!segments.includes('openid')) {
segments.unshift('openid');
}
const identities = {
id: user[claimsMap.id] as string,
email: user[claimsMap.email] as string,
};
return segments.join(' ');
}
if (!identities.id || !identities.email) {
throw new InvalidOauthResponse({
reason: `Missing required claims: ${Object.keys(identities)
.filter(key => !identities[key as keyof typeof identities])
.join(', ')}`,
});
private normalizeIssuer(issuer: string) {
return issuer.replace(/\/+$/, '');
}
private async verifyIdToken(idToken: string, nonce: string) {
try {
const { payload } = await jwtVerify(idToken, this.jwks, {
issuer: this.endpoints.issuer,
audience: this.config.clientId,
});
if (!payload.nonce || payload.nonce !== nonce) {
throw new InvalidAuthState();
}
return identities;
return payload;
} catch (err) {
this.logger.warn('Failed to verify OIDC id token', err);
throw new InvalidAuthState();
}
}
private extractBoolean(value: unknown): boolean | undefined {
if (typeof value === 'boolean') {
return value;
}
throw new InvalidOauthCallbackCode({
status: res.status,
body: await res.text(),
});
if (typeof value === 'string') {
const normalized = value.toLowerCase();
if (['true', '1', 'yes'].includes(normalized)) {
return true;
}
if (['false', '0', 'no'].includes(normalized)) {
return false;
}
}
return undefined;
}
private extractString(value: unknown): string | undefined {
if (typeof value === 'string' && value.length > 0) {
return value;
}
return undefined;
}
async getUser(tokens: Tokens, state: OAuthState): Promise<OAuthAccount> {
if (!tokens.idToken) {
throw new InvalidOauthResponse({
reason: 'Missing id_token in OIDC token response',
});
}
if (!state.token) {
throw new InvalidAuthState();
}
const idTokenClaims = await this.verifyIdToken(tokens.idToken, state.token);
const rawUser = await this.fetchJson<unknown>(
this.endpoints.userinfo_endpoint,
{
method: 'GET',
headers: {
Authorization: `Bearer ${tokens.accessToken}`,
},
},
{ treatServerErrorAsInvalid: true }
);
const user = OIDCUserInfoSchema.parse(rawUser);
if (!user.sub || !idTokenClaims.sub) {
throw new InvalidOauthResponse({
reason: 'Missing subject claim in OIDC response',
});
} else if (user.sub !== idTokenClaims.sub) {
throw new InvalidOauthResponse({
reason: 'Subject mismatch between ID token and userinfo response',
});
}
const args = this.config.args ?? {};
const claimsMap = {
id: args.claim_id || 'sub',
email: args.claim_email || 'email',
name: args.claim_name || 'name',
emailVerified: args.claim_email_verified || 'email_verified',
};
const accountId =
this.extractString(user[claimsMap.id]) ?? idTokenClaims.sub;
const email =
this.extractString(user[claimsMap.email]) ||
this.extractString(idTokenClaims.email);
const emailVerified =
this.extractBoolean(user[claimsMap.emailVerified]) ??
this.extractBoolean(idTokenClaims.email_verified);
if (!accountId) {
throw new InvalidOauthResponse({
reason: 'Missing required claim for user identifier',
});
}
if (!email) {
throw new InvalidOauthResponse({
reason: 'Missing required claim for email',
});
}
if (emailVerified === false) {
throw new InvalidOauthResponse({
reason: 'Email for this account is not verified',
});
}
const account: OAuthAccount = {
id: accountId,
email,
};
const name =
this.extractString(user[claimsMap.name]) ||
this.extractString(idTokenClaims.name);
if (name) {
account.name = name;
}
return account;
}
}