From f29e47e9d293645fff22f4ed4991667d9f0a5a2b Mon Sep 17 00:00:00 2001 From: DarkSky <25152247+darkskygit@users.noreply.github.com> Date: Mon, 8 Dec 2025 10:44:41 +0800 Subject: [PATCH] feat: improve oauth (#14061) fix #13730 fix #12901 fix #14025 --- packages/backend/server/package.json | 1 + .../src/__tests__/oauth/controller.spec.ts | 1 - .../server/src/plugins/oauth/config.ts | 2 + .../server/src/plugins/oauth/controller.ts | 29 +- .../src/plugins/oauth/providers/apple.ts | 62 ++-- .../server/src/plugins/oauth/providers/def.ts | 73 +++- .../src/plugins/oauth/providers/github.ts | 73 ++-- .../src/plugins/oauth/providers/google.ts | 70 ++-- .../src/plugins/oauth/providers/oidc.ts | 327 ++++++++++++++---- .../server/src/plugins/oauth/service.ts | 38 +- .../backend/server/src/plugins/oauth/types.ts | 19 + .../i18n/src/i18n-completenesses.json | 6 +- tests/affine-local/e2e/quick-search.spec.ts | 10 +- yarn.lock | 8 + 14 files changed, 495 insertions(+), 224 deletions(-) create mode 100644 packages/backend/server/src/plugins/oauth/types.ts diff --git a/packages/backend/server/package.json b/packages/backend/server/package.json index 7a68c7ace..53c793e61 100644 --- a/packages/backend/server/package.json +++ b/packages/backend/server/package.json @@ -95,6 +95,7 @@ "http-errors": "^2.0.0", "ioredis": "^5.4.1", "is-mobile": "^5.0.0", + "jose": "^6.1.3", "jsonwebtoken": "^9.0.2", "keyv": "^5.2.2", "lodash-es": "^4.17.21", diff --git a/packages/backend/server/src/__tests__/oauth/controller.spec.ts b/packages/backend/server/src/__tests__/oauth/controller.spec.ts index 8e62022f8..7fa6aa649 100644 --- a/packages/backend/server/src/__tests__/oauth/controller.spec.ts +++ b/packages/backend/server/src/__tests__/oauth/controller.spec.ts @@ -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', diff --git a/packages/backend/server/src/plugins/oauth/config.ts b/packages/backend/server/src/plugins/oauth/config.ts index 9a52a9a03..ac6137c8f 100644 --- a/packages/backend/server/src/plugins/oauth/config.ts +++ b/packages/backend/server/src/plugins/oauth/config.ts @@ -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(), }), }), }, diff --git a/packages/backend/server/src/plugins/oauth/controller.ts b/packages/backend/server/src/plugins/oauth/controller.ts index 550378771..2ddb49188 100644 --- a/packages/backend/server/src/plugins/oauth/controller.ts +++ b/packages/backend/server/src/plugins/oauth/controller.ts @@ -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 = { 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, }); diff --git a/packages/backend/server/src/plugins/oauth/providers/apple.ts b/packages/backend/server/src/plugins/oauth/providers/apple.ts index 0d94a9cb4..175b61df6 100644 --- a/packages/backend/server/src/plugins/oauth/providers/apple.ts +++ b/packages/backend/server/src/plugins/oauth/providers/apple.ts @@ -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( + '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((resolve, reject) => { jwt.verify( - tokens.idToken, + tokens.idToken!, (header, callback) => { const key = keys.find(key => key.kid === header.kid); if (!key) { diff --git a/packages/backend/server/src/plugins/oauth/providers/def.ts b/packages/backend/server/src/plugins/oauth/providers/def.ts index 95d9a250c..8258e860d 100644 --- a/packages/backend/server/src/plugins/oauth/providers/def.ts +++ b/packages/backend/server/src/plugins/oauth/providers/def.ts @@ -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; - abstract getUser(tokens: Tokens, state: any): Promise; + abstract getToken(code: string, state: OAuthState): Promise; + abstract getUser(tokens: Tokens, state: OAuthState): Promise; 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( + 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( + url: string, + body: string, + options?: { + headers?: Record; + treatServerErrorAsInvalid?: boolean; + } + ) { + return this.fetchJson( + url, + { + method: 'POST', + body, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + ...options?.headers, + }, + }, + options + ); + } } diff --git a/packages/backend/server/src/plugins/oauth/providers/github.ts b/packages/backend/server/src/plugins/oauth/providers/github.ts index c012f75eb..85fe970b1 100644 --- a/packages/backend/server/src/plugins/oauth/providers/github.ts +++ b/packages/backend/server/src/plugins/oauth/providers/github.ts @@ -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 { + const ghToken = await this.postFormJson( '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 { + const user = await this.fetchJson('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, + }; } } diff --git a/packages/backend/server/src/plugins/oauth/providers/google.ts b/packages/backend/server/src/plugins/oauth/providers/google.ts index d0a7d8edb..94e0cbdaa 100644 --- a/packages/backend/server/src/plugins/oauth/providers/google.ts +++ b/packages/backend/server/src/plugins/oauth/providers/google.ts @@ -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 { + const gToken = await this.postFormJson( + '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 { + const user = await this.fetchJson( '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, + }; } } diff --git a/packages/backend/server/src/plugins/oauth/providers/oidc.ts b/packages/backend/server/src/plugins/oauth/providers/oidc.ts index 1da1be693..775f55eba 100644 --- a/packages/backend/server/src/plugins/oauth/providers/oidc.ts +++ b/packages/backend/server/src/plugins/oauth/providers/oidc.ts @@ -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; @@ -40,11 +56,16 @@ type OIDCConfiguration = z.infer; export class OIDCProvider extends OAuthProvider { override provider = OAuthProviderName.OIDC; #endpoints: OIDCConfiguration | null = null; + #jwks: ReturnType | 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 { - const res = await fetch(this.endpoints.token_endpoint, { - method: 'POST', - body: this.url.stringify({ + async getToken(code: string, state: OAuthState): Promise { + if (this.requiresPkce && !state.pkce?.codeVerifier) { + throw new InvalidAuthState(); + } + + const data = await this.postFormJson( + 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 { - 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 { + 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( + 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; } } diff --git a/packages/backend/server/src/plugins/oauth/service.ts b/packages/backend/server/src/plugins/oauth/service.ts index b6f06b605..67da06bde 100644 --- a/packages/backend/server/src/plugins/oauth/service.ts +++ b/packages/backend/server/src/plugins/oauth/service.ts @@ -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(/=+$/, ''); + } } diff --git a/packages/backend/server/src/plugins/oauth/types.ts b/packages/backend/server/src/plugins/oauth/types.ts new file mode 100644 index 000000000..ba7598925 --- /dev/null +++ b/packages/backend/server/src/plugins/oauth/types.ts @@ -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; +} diff --git a/packages/frontend/i18n/src/i18n-completenesses.json b/packages/frontend/i18n/src/i18n-completenesses.json index d7d63f8c9..6888c029b 100644 --- a/packages/frontend/i18n/src/i18n-completenesses.json +++ b/packages/frontend/i18n/src/i18n-completenesses.json @@ -1,6 +1,6 @@ { "ar": 99, - "ca": 4, + "ca": 100, "da": 4, "de": 99, "el-GR": 99, @@ -9,12 +9,12 @@ "es-CL": 100, "es": 99, "fa": 99, - "fr": 99, + "fr": 100, "hi": 2, "it-IT": 99, "it": 1, "ja": 99, - "ko": 52, + "ko": 100, "pl": 99, "pt-BR": 99, "ru": 99, diff --git a/tests/affine-local/e2e/quick-search.spec.ts b/tests/affine-local/e2e/quick-search.spec.ts index fc40b7075..a3a77d544 100644 --- a/tests/affine-local/e2e/quick-search.spec.ts +++ b/tests/affine-local/e2e/quick-search.spec.ts @@ -178,7 +178,7 @@ test('Create a new page and search this page', async ({ page }) => { await assertTitle(page, 'test123456'); await openQuickSearchByShortcut(page); await insertInputText(page, 'test123456'); - await page.waitForTimeout(300); + await page.waitForTimeout(1000); await assertResultList(page, ['test123456', 'test123456']); await page.keyboard.press('Enter'); await page.waitForTimeout(300); @@ -188,7 +188,7 @@ test('Create a new page and search this page', async ({ page }) => { await waitForEditorLoad(page); await openQuickSearchByShortcut(page); await insertInputText(page, 'test123456'); - await page.waitForTimeout(300); + await page.waitForTimeout(1000); await assertResultList(page, ['test123456', 'test123456']); await page.keyboard.press('Enter'); await page.waitForTimeout(300); @@ -388,7 +388,7 @@ test('can use cmdk to search page content and scroll to it, then the block will await clickSideBarAllPageButton(page); await openQuickSearchByShortcut(page); await insertInputText(page, '123456'); - await page.waitForTimeout(300); + await page.waitForTimeout(1000); await assertResultList(page, [ 'this is a new page to search for content', '123456', @@ -420,11 +420,11 @@ test('Create a new page with special characters in the title and search for this await openQuickSearchByShortcut(page); await insertInputText(page, specialTitle); - await page.waitForTimeout(300); + await page.waitForTimeout(1000); await assertResultList(page, [specialTitle, specialTitle]); await page.keyboard.press('Enter'); - await page.waitForTimeout(300); + await page.waitForTimeout(1000); await assertTitle(page, specialTitle); }); diff --git a/yarn.lock b/yarn.lock index 68bed7e77..7e025251e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1010,6 +1010,7 @@ __metadata: http-errors: "npm:^2.0.0" ioredis: "npm:^5.4.1" is-mobile: "npm:^5.0.0" + jose: "npm:^6.1.3" jsonwebtoken: "npm:^9.0.2" keyv: "npm:^5.2.2" lodash-es: "npm:^4.17.21" @@ -25699,6 +25700,13 @@ __metadata: languageName: node linkType: hard +"jose@npm:^6.1.3": + version: 6.1.3 + resolution: "jose@npm:6.1.3" + checksum: 10/9626c51e8c3792b505e954f3094698c182208617b62dfb27269230f31e57560b083985ed8128b8a9753aa92daf18d3a2341cc826d149503f14569abe87d42389 + languageName: node + linkType: hard + "jotai-effect@npm:^2.0.0": version: 2.0.2 resolution: "jotai-effect@npm:2.0.2"