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

View File

@@ -331,7 +331,6 @@ function mockOAuthProvider(
clientNonce,
});
// @ts-expect-error mock
Sinon.stub(provider, 'getToken').resolves({ accessToken: '1' });
Sinon.stub(provider, 'getUser').resolves({
id: '1',

View File

@@ -13,6 +13,7 @@ export type OIDCArgs = {
claim_id?: string;
claim_email?: string;
claim_name?: string;
claim_email_verified?: string;
};
export interface OAuthOIDCProviderConfig extends OAuthProviderConfig {
@@ -88,6 +89,7 @@ defineModuleConfig('oauth', {
claim_id: z.string().optional(),
claim_email: z.string().optional(),
claim_name: z.string().optional(),
claim_email_verified: z.string().optional(),
}),
}),
},

View File

@@ -65,18 +65,37 @@ export class OAuthController {
throw new UnknownOauthProvider({ name: unknownProviderName });
}
const pkce = provider.requiresPkce ? this.oauth.createPkcePair() : null;
const state = await this.oauth.saveOAuthState({
provider: providerName,
redirectUri,
client,
clientNonce,
...(pkce
? {
pkce: {
codeVerifier: pkce.codeVerifier,
codeChallengeMethod: pkce.codeChallengeMethod,
},
}
: {}),
});
const stateStr = JSON.stringify({
const statePayload: Record<string, unknown> = {
state,
client,
provider: unknownProviderName,
});
};
if (pkce) {
statePayload.pkce = {
codeChallenge: pkce.codeChallenge,
codeChallengeMethod: pkce.codeChallengeMethod,
};
}
const stateStr = JSON.stringify(statePayload);
return {
url: provider.getAuthUrl(stateStr, clientNonce),
@@ -125,6 +144,9 @@ export class OAuthController {
if (!state) {
throw new OauthStateExpired();
}
if (!state.token) {
state.token = stateStr;
}
if (
state.provider === OAuthProviderName.Apple &&
@@ -173,7 +195,7 @@ export class OAuthController {
let tokens: Tokens;
try {
tokens = await provider.getToken(code);
tokens = await provider.getToken(code, state);
} catch (err) {
let rayBodyString = '';
if (req.rawBody) {
@@ -238,6 +260,7 @@ export class OAuthController {
}
const user = await this.models.user.fulfill(externalAccount.email, {
name: externalAccount.name,
avatarUrl: externalAccount.avatarUrl,
});

View File

@@ -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) {

View File

@@ -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
);
}
}

View File

@@ -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,
};
}
}

View File

@@ -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,
};
}
}

View File

@@ -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;
}
}

View File

@@ -1,20 +1,13 @@
import { randomUUID } from 'node:crypto';
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import { SessionCache } from '../../base';
import { OAuthProviderName } from './config';
import { OAuthProviderFactory } from './factory';
import { OAuthPkceChallenge, OAuthState } from './types';
const OAUTH_STATE_KEY = 'OAUTH_STATE';
interface OAuthState {
redirectUri?: string;
client?: string;
clientNonce?: string;
provider: OAuthProviderName;
}
@Injectable()
export class OAuthService {
constructor(
@@ -28,7 +21,8 @@ export class OAuthService {
async saveOAuthState(state: OAuthState) {
const token = randomUUID();
await this.cache.set(`${OAUTH_STATE_KEY}:${token}`, state, {
const payload: OAuthState = { ...state, token };
await this.cache.set(`${OAUTH_STATE_KEY}:${token}`, payload, {
ttl: 3600 * 3 * 1000 /* 3 hours */,
});
@@ -42,4 +36,28 @@ export class OAuthService {
availableOAuthProviders() {
return this.providerFactory.providers;
}
createPkcePair(): OAuthPkceChallenge {
const codeVerifier = this.randomBase64Url(96);
const hash = createHash('sha256').update(codeVerifier).digest();
const codeChallenge = this.base64UrlEncode(hash);
return {
codeVerifier,
codeChallenge,
codeChallengeMethod: 'S256',
};
}
private randomBase64Url(byteLength: number) {
return this.base64UrlEncode(randomBytes(byteLength));
}
private base64UrlEncode(buffer: Buffer) {
return buffer
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
}

View File

@@ -0,0 +1,19 @@
import { OAuthProviderName } from './config';
export interface OAuthPkceState {
codeVerifier: string;
codeChallengeMethod: 'S256';
}
export interface OAuthPkceChallenge extends OAuthPkceState {
codeChallenge: string;
}
export interface OAuthState {
redirectUri?: string;
client?: string;
clientNonce?: string;
provider: OAuthProviderName;
pkce?: OAuthPkceState;
token?: string;
}