From 41781902f635a40fa94677c61b837c45e5672a59 Mon Sep 17 00:00:00 2001 From: liuyi Date: Fri, 23 May 2025 15:27:27 +0800 Subject: [PATCH] feat(core): support apple sign in (#12424) --- .docker/selfhost/schema.json | 32 +- packages/backend/server/package.json | 2 + packages/backend/server/src/base/error/def.ts | 5 + .../server/src/base/error/errors.gen.ts | 13 +- .../server/src/plugins/oauth/config.ts | 28 ++ .../server/src/plugins/oauth/controller.ts | 86 +++++- .../src/plugins/oauth/providers/apple.ts | 131 ++++++++ .../server/src/plugins/oauth/providers/def.ts | 15 +- .../src/plugins/oauth/providers/github.ts | 6 +- .../src/plugins/oauth/providers/google.ts | 6 +- .../src/plugins/oauth/providers/index.ts | 2 + .../src/plugins/oauth/providers/oidc.ts | 284 ++++++++---------- packages/backend/server/src/schema.gql | 8 +- packages/common/env/src/ua-helper.ts | 2 +- packages/common/graphql/src/schema.ts | 8 + packages/frontend/admin/src/config.json | 8 +- .../core/src/components/affine/auth/oauth.tsx | 143 ++++----- .../src/desktop/pages/auth/oauth-login.tsx | 4 +- .../core/src/modules/cloud/services/auth.ts | 6 +- packages/frontend/i18n/src/i18n.gen.ts | 6 + packages/frontend/i18n/src/resources/en.json | 1 + yarn.lock | 93 ++++++ 22 files changed, 629 insertions(+), 260 deletions(-) create mode 100644 packages/backend/server/src/plugins/oauth/providers/apple.ts diff --git a/.docker/selfhost/schema.json b/.docker/selfhost/schema.json index be6e33f8e..396df08cf 100644 --- a/.docker/selfhost/schema.json +++ b/.docker/selfhost/schema.json @@ -891,13 +891,43 @@ }, "providers.oidc": { "type": "object", - "description": "OIDC OAuth provider config\n@default {\"clientId\":\"\",\"clientSecret\":\"\",\"issuer\":\"\",\"args\":{}}", + "description": "OIDC OAuth provider config\n@default {\"clientId\":\"\",\"clientSecret\":\"\",\"issuer\":\"\",\"args\":{}}\n@link https://openid.net/specs/openid-connect-core-1_0.html", + "properties": { + "clientId": { + "type": "string" + }, + "clientSecret": { + "type": "string" + }, + "args": { + "type": "object" + } + }, "default": { "clientId": "", "clientSecret": "", "issuer": "", "args": {} } + }, + "providers.apple": { + "type": "object", + "description": "Apple OAuth provider config\n@default {\"clientId\":\"\",\"clientSecret\":\"\"}\n@link https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/implementing_sign_in_with_apple_in_your_app", + "properties": { + "clientId": { + "type": "string" + }, + "clientSecret": { + "type": "string" + }, + "args": { + "type": "object" + } + }, + "default": { + "clientId": "", + "clientSecret": "" + } } } }, diff --git a/packages/backend/server/package.json b/packages/backend/server/package.json index 2476bd800..c784d24cb 100644 --- a/packages/backend/server/package.json +++ b/packages/backend/server/package.json @@ -91,6 +91,7 @@ "http-errors": "^2.0.0", "ioredis": "^5.4.1", "is-mobile": "^5.0.0", + "jsonwebtoken": "^9.0.2", "keyv": "^5.2.2", "lodash-es": "^4.17.21", "mixpanel": "^0.18.0", @@ -129,6 +130,7 @@ "@types/express-serve-static-core": "^5.0.6", "@types/graphql-upload": "^17.0.0", "@types/http-errors": "^2.0.4", + "@types/jsonwebtoken": "^9.0.9", "@types/lodash-es": "^4.17.12", "@types/mixpanel": "^2.14.9", "@types/mustache": "^4.2.5", diff --git a/packages/backend/server/src/base/error/def.ts b/packages/backend/server/src/base/error/def.ts index 7f972ecdf..890d58196 100644 --- a/packages/backend/server/src/base/error/def.ts +++ b/packages/backend/server/src/base/error/def.ts @@ -350,6 +350,11 @@ export const USER_FRIENDLY_ERRORS = { message: 'The third-party account has already been connected to another user.', }, + invalid_oauth_response: { + type: 'bad_request', + args: { reason: 'string' }, + message: ({ reason }) => `Invalid OAuth response: ${reason}.`, + }, invalid_email: { type: 'invalid_input', args: { email: 'string' }, diff --git a/packages/backend/server/src/base/error/errors.gen.ts b/packages/backend/server/src/base/error/errors.gen.ts index 8c5419661..eb3720b84 100644 --- a/packages/backend/server/src/base/error/errors.gen.ts +++ b/packages/backend/server/src/base/error/errors.gen.ts @@ -170,6 +170,16 @@ export class OauthAccountAlreadyConnected extends UserFriendlyError { } } @ObjectType() +class InvalidOauthResponseDataType { + @Field() reason!: string +} + +export class InvalidOauthResponse extends UserFriendlyError { + constructor(args: InvalidOauthResponseDataType, message?: string | ((args: InvalidOauthResponseDataType) => string)) { + super('bad_request', 'invalid_oauth_response', message, args); + } +} +@ObjectType() class InvalidEmailDataType { @Field() email!: string } @@ -1058,6 +1068,7 @@ export enum ErrorNames { INVALID_AUTH_STATE, MISSING_OAUTH_QUERY_PARAMETER, OAUTH_ACCOUNT_ALREADY_CONNECTED, + INVALID_OAUTH_RESPONSE, INVALID_EMAIL, INVALID_PASSWORD_LENGTH, PASSWORD_REQUIRED, @@ -1176,5 +1187,5 @@ registerEnumType(ErrorNames, { export const ErrorDataUnionType = createUnionType({ name: 'ErrorDataUnion', types: () => - [GraphqlBadRequestDataType, HttpRequestErrorDataType, QueryTooLongDataType, ValidationErrorDataType, WrongSignInCredentialsDataType, UnknownOauthProviderDataType, InvalidOauthCallbackCodeDataType, MissingOauthQueryParameterDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, WorkspacePermissionNotFoundDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, SpaceShouldHaveOnlyOneOwnerDataType, DocNotFoundDataType, DocActionDeniedDataType, DocUpdateBlockedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, ExpectToGrantDocUserRolesDataType, ExpectToRevokeDocUserRolesDataType, ExpectToUpdateDocUserRoleDataType, NoMoreSeatDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CopilotDocNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderNotSupportedDataType, CopilotProviderSideErrorDataType, CopilotInvalidContextDataType, CopilotContextFileNotSupportedDataType, CopilotFailedToModifyContextDataType, CopilotFailedToMatchContextDataType, CopilotFailedToMatchGlobalContextDataType, CopilotFailedToAddWorkspaceFileEmbeddingDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType, InvalidLicenseToActivateDataType, InvalidLicenseUpdateParamsDataType, UnsupportedClientVersionDataType, MentionUserDocAccessDeniedDataType, InvalidSearchProviderRequestDataType, InvalidIndexerInputDataType] as const, + [GraphqlBadRequestDataType, HttpRequestErrorDataType, QueryTooLongDataType, ValidationErrorDataType, WrongSignInCredentialsDataType, UnknownOauthProviderDataType, InvalidOauthCallbackCodeDataType, MissingOauthQueryParameterDataType, InvalidOauthResponseDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, WorkspacePermissionNotFoundDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, SpaceShouldHaveOnlyOneOwnerDataType, DocNotFoundDataType, DocActionDeniedDataType, DocUpdateBlockedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, ExpectToGrantDocUserRolesDataType, ExpectToRevokeDocUserRolesDataType, ExpectToUpdateDocUserRoleDataType, NoMoreSeatDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CopilotDocNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderNotSupportedDataType, CopilotProviderSideErrorDataType, CopilotInvalidContextDataType, CopilotContextFileNotSupportedDataType, CopilotFailedToModifyContextDataType, CopilotFailedToMatchContextDataType, CopilotFailedToMatchGlobalContextDataType, CopilotFailedToAddWorkspaceFileEmbeddingDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType, InvalidLicenseToActivateDataType, InvalidLicenseUpdateParamsDataType, UnsupportedClientVersionDataType, MentionUserDocAccessDeniedDataType, InvalidSearchProviderRequestDataType, InvalidIndexerInputDataType] as const, }); diff --git a/packages/backend/server/src/plugins/oauth/config.ts b/packages/backend/server/src/plugins/oauth/config.ts index 11bb74748..2e953ffb5 100644 --- a/packages/backend/server/src/plugins/oauth/config.ts +++ b/packages/backend/server/src/plugins/oauth/config.ts @@ -1,3 +1,5 @@ +import { z } from 'zod'; + import { defineModuleConfig, JSONSchema } from '../../base'; export interface OAuthProviderConfig { @@ -21,6 +23,7 @@ export interface OAuthOIDCProviderConfig extends OAuthProviderConfig { export enum OAuthProviderName { Google = 'google', GitHub = 'github', + Apple = 'apple', OIDC = 'oidc', } declare global { @@ -29,6 +32,7 @@ declare global { providers: { [OAuthProviderName.Google]: ConfigItem; [OAuthProviderName.GitHub]: ConfigItem; + [OAuthProviderName.Apple]: ConfigItem; [OAuthProviderName.OIDC]: ConfigItem; }; }; @@ -71,5 +75,29 @@ defineModuleConfig('oauth', { issuer: '', args: {}, }, + schema, + link: 'https://openid.net/specs/openid-connect-core-1_0.html', + shape: z.object({ + issuer: z + .string() + .url() + .regex(/^https?:\/\//, 'issuer must be a valid URL') + .or(z.string().length(0)), + args: z.object({ + scope: z.string().optional(), + claim_id: z.string().optional(), + claim_email: z.string().optional(), + claim_name: z.string().optional(), + }), + }), + }, + 'providers.apple': { + desc: 'Apple OAuth provider config', + default: { + clientId: '', + clientSecret: '', + }, + schema, + link: 'https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/implementing_sign_in_with_apple_in_your_app', }, }); diff --git a/packages/backend/server/src/plugins/oauth/controller.ts b/packages/backend/server/src/plugins/oauth/controller.ts index d3eb241e0..69b872614 100644 --- a/packages/backend/server/src/plugins/oauth/controller.ts +++ b/packages/backend/server/src/plugins/oauth/controller.ts @@ -19,6 +19,7 @@ import { OauthAccountAlreadyConnected, OauthStateExpired, UnknownOauthProvider, + URLHelper, UseNamedGuard, } from '../../base'; import { AuthService, Public } from '../../core/auth'; @@ -36,7 +37,8 @@ export class OAuthController { private readonly auth: AuthService, private readonly oauth: OAuthService, private readonly models: Models, - private readonly providerFactory: OAuthProviderFactory + private readonly providerFactory: OAuthProviderFactory, + private readonly url: URLHelper ) {} @Public() @@ -67,10 +69,14 @@ export class OAuthController { clientNonce, }); + const stateStr = JSON.stringify({ + state, + client, + provider: unknownProviderName, + }); + return { - url: provider.getAuthUrl( - JSON.stringify({ state, client, provider: unknownProviderName }) - ), + url: provider.getAuthUrl(stateStr, clientNonce), }; } @@ -85,6 +91,7 @@ export class OAuthController { @Body('state') stateStr?: string, @Body('client_nonce') clientNonce?: string ) { + // TODO(@forehalo): refactor and remove deprecated code in 0.23 if (!code) { throw new MissingOauthQueryParameter({ name: 'code' }); } @@ -93,6 +100,17 @@ export class OAuthController { throw new MissingOauthQueryParameter({ name: 'state' }); } + // NOTE(@forehalo): Apple sign in will directly post /callback, with `state` set at #L73 + let rawState = null; + if (typeof stateStr === 'string' && stateStr.length > 36) { + try { + rawState = JSON.parse(stateStr); + stateStr = rawState.state; + } catch { + /* noop */ + } + } + if (typeof stateStr !== 'string' || !this.oauth.isValidState(stateStr)) { throw new InvalidOauthCallbackState(); } @@ -103,8 +121,38 @@ export class OAuthController { throw new OauthStateExpired(); } + if ( + state.provider === OAuthProviderName.Apple && + rawState && + state.client && + state.client !== 'web' + ) { + const clientUrl = new URL(`${state.client}://authentication`); + clientUrl.searchParams.set('method', 'oauth'); + clientUrl.searchParams.set( + 'payload', + JSON.stringify({ + state: stateStr, + code, + provider: rawState.provider, + }) + ); + clientUrl.searchParams.set('server', this.url.origin); + + return res.redirect( + this.url.link('/open-app/url?', { + url: clientUrl.toString(), + }) + ); + } + // TODO(@fengmk2): clientNonce should be required after the client version >= 0.21.0 - if (state.clientNonce && state.clientNonce !== clientNonce) { + if ( + state.clientNonce && + state.clientNonce !== clientNonce && + // apple sign in with nonce stored in id token + state.provider !== OAuthProviderName.Apple + ) { throw new InvalidAuthState(); } @@ -132,7 +180,8 @@ export class OAuthController { ); throw err; } - const externAccount = await provider.getUser(tokens.accessToken); + + const externAccount = await provider.getUser(tokens, state); const user = await this.loginFromOauth( state.provider, externAccount, @@ -140,6 +189,14 @@ export class OAuthController { ); await this.auth.setCookies(req, res, user.id); + + if ( + state.provider === OAuthProviderName.Apple && + (!state.client || state.client === 'web') + ) { + return res.redirect(this.url.link(state.redirectUri ?? '/')); + } + res.send({ id: user.id, redirectUri: state.redirectUri, @@ -170,7 +227,9 @@ export class OAuthController { userId: user.id, provider, providerAccountId: externalAccount.id, - ...tokens, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + expiresAt: tokens.expiresAt, }); return user; @@ -180,10 +239,11 @@ export class OAuthController { connectedAccount: ConnectedAccount, tokens: Tokens ) { - return await this.models.user.updateConnectedAccount( - connectedAccount.id, - tokens - ); + return await this.models.user.updateConnectedAccount(connectedAccount.id, { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + expiresAt: tokens.expiresAt, + }); } /** @@ -210,7 +270,9 @@ export class OAuthController { userId: user.id, provider, providerAccountId: externalAccount.id, - ...tokens, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + expiresAt: tokens.expiresAt, }); } } diff --git a/packages/backend/server/src/plugins/oauth/providers/apple.ts b/packages/backend/server/src/plugins/oauth/providers/apple.ts new file mode 100644 index 000000000..3d46df46b --- /dev/null +++ b/packages/backend/server/src/plugins/oauth/providers/apple.ts @@ -0,0 +1,131 @@ +import { JsonWebKey } from 'node:crypto'; + +import { Injectable } from '@nestjs/common'; +import jwt, { type JwtPayload } from 'jsonwebtoken'; + +import { + InternalServerError, + InvalidOauthCallbackCode, + URLHelper, +} from '../../../base'; +import { OAuthProviderName } from '../config'; +import { OAuthProvider, Tokens } from './def'; + +interface AuthTokenResponse { + access_token: string; + refresh_token: string; + id_token: string; + token_type: string; + expires_in: number; +} + +@Injectable() +export class AppleOAuthProvider extends OAuthProvider { + provider = OAuthProviderName.Apple; + + constructor(private readonly url: URLHelper) { + super(); + } + + getAuthUrl(state: string, clientNonce?: string): string { + return `https://appleid.apple.com/auth/authorize?${this.url.stringify({ + client_id: this.config.clientId, + redirect_uri: this.url.link('/api/oauth/callback'), + scope: 'name email', + response_type: 'code', + response_mode: 'form_post', + ...this.config.args, + state, + nonce: clientNonce, + })}`; + } + + async getToken(code: string) { + const response = await fetch('https://appleid.apple.com/auth/token', { + method: 'POST', + body: this.url.stringify({ + code, + client_id: this.config.clientId, + client_secret: this.config.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}` + ); + } + } + + 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[] }; + + const payload = await new Promise((resolve, reject) => { + jwt.verify( + tokens.idToken, + (header, callback) => { + const key = keys.find(key => key.kid === header.kid); + if (!key) { + callback( + new InternalServerError( + 'Cannot find match apple public sign key.' + ) + ); + } else { + callback(null, { + format: 'jwk', + key, + }); + } + }, + { + issuer: 'https://appleid.apple.com', + audience: this.config.clientId, + nonce: state.clientNonce, + }, + (err, payload) => { + if (err || !payload || typeof payload === 'string') { + reject(err || new InternalServerError('Invalid jwt payload')); + return; + } + resolve(payload); + } + ); + }); + + // see https://developer.apple.com/documentation/signinwithapple/authenticating-users-with-sign-in-with-apple + if (!payload.sub || !payload.email) { + throw new Error('Invalid jwt payload'); + } + + return { + id: payload.sub, + email: payload.email, + }; + } +} diff --git a/packages/backend/server/src/plugins/oauth/providers/def.ts b/packages/backend/server/src/plugins/oauth/providers/def.ts index a1ed8c937..4bc97abe6 100644 --- a/packages/backend/server/src/plugins/oauth/providers/def.ts +++ b/packages/backend/server/src/plugins/oauth/providers/def.ts @@ -17,12 +17,19 @@ export interface Tokens { expiresAt?: Date; } +export interface AuthOptions { + client_id: string; + redirect_uri: string; + scope: string; + state: string; +} + @Injectable() export abstract class OAuthProvider { abstract provider: OAuthProviderName; - abstract getAuthUrl(state: string): string; + abstract getAuthUrl(state: string, clientNonce?: string): string; abstract getToken(code: string): Promise; - abstract getUser(token: string): Promise; + abstract getUser(tokens: Tokens, state: any): Promise; protected readonly logger = new Logger(this.constructor.name); @Inject() private readonly factory!: OAuthProviderFactory; @@ -33,7 +40,9 @@ export abstract class OAuthProvider { } get configured() { - return this.config && this.config.clientId && this.config.clientSecret; + return ( + !!this.config && !!this.config.clientId && !!this.config.clientSecret + ); } @OnEvent('config.init') diff --git a/packages/backend/server/src/plugins/oauth/providers/github.ts b/packages/backend/server/src/plugins/oauth/providers/github.ts index 25f7f36bd..c012f75eb 100644 --- a/packages/backend/server/src/plugins/oauth/providers/github.ts +++ b/packages/backend/server/src/plugins/oauth/providers/github.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { InvalidOauthCallbackCode, URLHelper } from '../../../base'; import { OAuthProviderName } from '../config'; -import { OAuthProvider } from './def'; +import { OAuthProvider, Tokens } from './def'; interface AuthTokenResponse { access_token: string; @@ -71,11 +71,11 @@ export class GithubOAuthProvider extends OAuthProvider { } } - async getUser(token: string) { + async getUser(tokens: Tokens) { const response = await fetch('https://api.github.com/user', { method: 'GET', headers: { - Authorization: `Bearer ${token}`, + Authorization: `Bearer ${tokens.accessToken}`, }, }); diff --git a/packages/backend/server/src/plugins/oauth/providers/google.ts b/packages/backend/server/src/plugins/oauth/providers/google.ts index 9a48f16f1..d0a7d8edb 100644 --- a/packages/backend/server/src/plugins/oauth/providers/google.ts +++ b/packages/backend/server/src/plugins/oauth/providers/google.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { InvalidOauthCallbackCode, URLHelper } from '../../../base'; import { OAuthProviderName } from '../config'; -import { OAuthProvider } from './def'; +import { OAuthProvider, Tokens } from './def'; interface GoogleOAuthTokenResponse { access_token: string; @@ -76,13 +76,13 @@ export class GoogleOAuthProvider extends OAuthProvider { } } - async getUser(token: string) { + async getUser(tokens: Tokens) { const response = await fetch( 'https://www.googleapis.com/oauth2/v2/userinfo', { method: 'GET', headers: { - Authorization: `Bearer ${token}`, + Authorization: `Bearer ${tokens.accessToken}`, }, } ); diff --git a/packages/backend/server/src/plugins/oauth/providers/index.ts b/packages/backend/server/src/plugins/oauth/providers/index.ts index 0563da7da..1da0cebe7 100644 --- a/packages/backend/server/src/plugins/oauth/providers/index.ts +++ b/packages/backend/server/src/plugins/oauth/providers/index.ts @@ -1,3 +1,4 @@ +import { AppleOAuthProvider } from './apple'; import { GithubOAuthProvider } from './github'; import { GoogleOAuthProvider } from './google'; import { OIDCProvider } from './oidc'; @@ -6,4 +7,5 @@ export const OAuthProviders = [ GoogleOAuthProvider, GithubOAuthProvider, OIDCProvider, + AppleOAuthProvider, ]; diff --git a/packages/backend/server/src/plugins/oauth/providers/oidc.ts b/packages/backend/server/src/plugins/oauth/providers/oidc.ts index 0ddeba348..fd153bebc 100644 --- a/packages/backend/server/src/plugins/oauth/providers/oidc.ts +++ b/packages/backend/server/src/plugins/oauth/providers/oidc.ts @@ -1,12 +1,13 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; +import { omit } from 'lodash-es'; import { z } from 'zod'; -import { URLHelper } from '../../../base'; import { - OAuthOIDCProviderConfig, - OAuthProviderName, - OIDCArgs, -} from '../config'; + InvalidOauthCallbackCode, + InvalidOauthResponse, + URLHelper, +} from '../../../base'; +import { OAuthOIDCProviderConfig, OAuthProviderName } from '../config'; import { OAuthAccount, OAuthProvider, Tokens } from './def'; const OIDCTokenSchema = z.object({ @@ -27,8 +28,6 @@ const OIDCUserInfoSchema = z }) .passthrough(); -type OIDCUserInfo = z.infer; - const OIDCConfigurationSchema = z.object({ authorization_endpoint: z.string().url(), token_endpoint: z.string().url(), @@ -37,173 +36,142 @@ const OIDCConfigurationSchema = z.object({ type OIDCConfiguration = z.infer; -const logger = new Logger('OIDCClient'); - -class OIDCClient { - private static async fetch( - url: string, - options: RequestInit, - verifier: z.Schema - ): Promise { - const response = await fetch(url, options); - - if (!response.ok) { - logger.error('Failed to fetch OIDC configuration', await response.json()); - throw new Error(`Failed to configure client`); - } - const data = await response.json(); - return verifier.parse(data); - } - - static async create(config: OAuthOIDCProviderConfig, url: URLHelper) { - const { args, clientId, clientSecret, issuer } = config; - if (!url.verify(issuer)) { - throw new Error('OIDC Issuer is invalid.'); - } - const oidcConfig = await OIDCClient.fetch( - `${issuer}/.well-known/openid-configuration`, - { - method: 'GET', - headers: { Accept: 'application/json' }, - }, - OIDCConfigurationSchema - ); - - return new OIDCClient(clientId, clientSecret, args, oidcConfig, url); - } - - private constructor( - private readonly clientId: string, - private readonly clientSecret: string, - private readonly args: OIDCArgs | undefined, - private readonly config: OIDCConfiguration, - private readonly url: URLHelper - ) {} - - authorize(state: string): string { - const args = Object.assign({}, this.args); - if ('claim_id' in args) delete args.claim_id; - if ('claim_email' in args) delete args.claim_email; - if ('claim_name' in args) delete args.claim_name; - - return `${this.config.authorization_endpoint}?${this.url.stringify({ - client_id: this.clientId, - redirect_uri: this.url.link('/oauth/callback'), - response_type: 'code', - ...args, - scope: this.args?.scope || 'openid profile email', - state, - })}`; - } - - async token(code: string): Promise { - const token = await OIDCClient.fetch( - this.config.token_endpoint, - { - method: 'POST', - body: this.url.stringify({ - code, - client_id: this.clientId, - client_secret: this.clientSecret, - redirect_uri: this.url.link('/oauth/callback'), - grant_type: 'authorization_code', - }), - headers: { - Accept: 'application/json', - 'Content-Type': 'application/x-www-form-urlencoded', - }, - }, - OIDCTokenSchema - ); - - return { - accessToken: token.access_token, - refreshToken: token.refresh_token, - expiresAt: new Date(Date.now() + token.expires_in * 1000), - scope: token.scope, - }; - } - - private mapUserInfo( - user: OIDCUserInfo, - claimsMap: Record - ): OAuthAccount { - const mappedUser: Partial = {}; - for (const [key, value] of Object.entries(claimsMap)) { - const claimValue = user[value]; - if (claimValue !== undefined) { - mappedUser[key as keyof OAuthAccount] = claimValue as string; - } - } - return mappedUser as OAuthAccount; - } - - async userinfo(token: string) { - const user = await OIDCClient.fetch( - this.config.userinfo_endpoint, - { - method: 'GET', - headers: { - Accept: 'application/json', - Authorization: `Bearer ${token}`, - }, - }, - OIDCUserInfoSchema - ); - - const claimsMap = { - id: this.args?.claim_id || 'preferred_username', - email: this.args?.claim_email || 'email', - name: this.args?.claim_name || 'name', - }; - const userinfo = this.mapUserInfo(user, claimsMap); - return { id: userinfo.id, email: userinfo.email }; - } -} - @Injectable() export class OIDCProvider extends OAuthProvider { override provider = OAuthProviderName.OIDC; - private client: OIDCClient | null = null; + #endpoints: OIDCConfiguration | null = null; constructor(private readonly url: URLHelper) { super(); } - protected override setup() { - super.setup(); - if (this.configured) { - OIDCClient.create(this.config as OAuthOIDCProviderConfig, this.url) - .then(client => { - this.client = client; - }) - .catch(e => { - this.logger.error('Failed to create OIDC client', e); - }); - } else { - this.client = null; + private get endpoints() { + if (!this.#endpoints) { + throw new Error('OIDC provider is not configured'); } + return this.#endpoints; } - private checkOIDCClient( - client: OIDCClient | null - ): asserts client is OIDCClient { - if (!client) { - throw new Error('OIDC client has not been loaded yet.'); - } + override get configured() { + return this.#endpoints !== null; + } + + protected override setup() { + const validate = async () => { + this.#endpoints = null; + + if (this.configured) { + const config = this.config as OAuthOIDCProviderConfig; + try { + const res = await fetch( + `${config.issuer}/.well-known/openid-configuration`, + { + method: 'GET', + headers: { Accept: 'application/json' }, + } + ); + + if (res.ok) { + this.#endpoints = OIDCConfigurationSchema.parse(await res.json()); + super.setup(); + } else { + this.logger.error(`Invalid OIDC issuer ${config.issuer}`); + } + } catch (e) { + this.logger.error('Failed to validate OIDC configuration', e); + } + } + }; + + validate().catch(() => { + /* noop */ + }); } getAuthUrl(state: string): string { - this.checkOIDCClient(this.client); - return this.client.authorize(state); + return `${this.endpoints.authorization_endpoint}?${this.url.stringify({ + client_id: this.config.clientId, + redirect_uri: this.url.link('/oauth/callback'), + scope: this.config.args?.scope || 'openid profile email', + response_type: 'code', + ...omit(this.config.args, 'claim_id', 'claim_email', 'claim_name'), + state, + })}`; } async getToken(code: string): Promise { - this.checkOIDCClient(this.client); - return await this.client.token(code); + const res = await fetch(this.endpoints.token_endpoint, { + method: 'POST', + body: 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 (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, + }; + } + + throw new InvalidOauthCallbackCode({ + status: res.status, + body: await res.text(), + }); } - async getUser(token: string): Promise { - this.checkOIDCClient(this.client); - return await this.client.userinfo(token); + + async getUser(tokens: Tokens): Promise { + const res = await fetch(this.endpoints.userinfo_endpoint, { + method: 'GET', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${tokens.accessToken}`, + }, + }); + + if (res.ok) { + const body = await res.json(); + const user = OIDCUserInfoSchema.parse(body); + + const args = this.config.args ?? {}; + + const claimsMap = { + id: args.claim_id || 'preferred_username', + email: args.claim_email || 'email', + name: args.claim_name || 'name', + }; + + const identities = { + id: user[claimsMap.id] as string, + email: user[claimsMap.email] as string, + }; + + if (!identities.id || !identities.email) { + throw new InvalidOauthResponse({ + reason: `Missing required claims: ${Object.keys(identities) + .filter(key => !identities[key as keyof typeof identities]) + .join(', ')}`, + }); + } + + return identities; + } + + throw new InvalidOauthCallbackCode({ + status: res.status, + body: await res.text(), + }); } } diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 6f8538093..7a377efef 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -523,7 +523,7 @@ type EditorType { name: String! } -union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CopilotContextFileNotSupportedDataType | CopilotDocNotFoundDataType | CopilotFailedToAddWorkspaceFileEmbeddingDataType | CopilotFailedToMatchContextDataType | CopilotFailedToMatchGlobalContextDataType | CopilotFailedToModifyContextDataType | CopilotInvalidContextDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderNotSupportedDataType | CopilotProviderSideErrorDataType | DocActionDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | DocUpdateBlockedDataType | ExpectToGrantDocUserRolesDataType | ExpectToRevokeDocUserRolesDataType | ExpectToUpdateDocUserRoleDataType | GraphqlBadRequestDataType | HttpRequestErrorDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType | InvalidIndexerInputDataType | InvalidLicenseToActivateDataType | InvalidLicenseUpdateParamsDataType | InvalidOauthCallbackCodeDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | InvalidSearchProviderRequestDataType | MemberNotFoundInSpaceDataType | MentionUserDocAccessDeniedDataType | MissingOauthQueryParameterDataType | NoMoreSeatDataType | NotInSpaceDataType | QueryTooLongDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType | SpaceShouldHaveOnlyOneOwnerDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | UnsupportedClientVersionDataType | UnsupportedSubscriptionPlanDataType | ValidationErrorDataType | VersionRejectedDataType | WorkspacePermissionNotFoundDataType | WrongSignInCredentialsDataType +union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CopilotContextFileNotSupportedDataType | CopilotDocNotFoundDataType | CopilotFailedToAddWorkspaceFileEmbeddingDataType | CopilotFailedToMatchContextDataType | CopilotFailedToMatchGlobalContextDataType | CopilotFailedToModifyContextDataType | CopilotInvalidContextDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderNotSupportedDataType | CopilotProviderSideErrorDataType | DocActionDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | DocUpdateBlockedDataType | ExpectToGrantDocUserRolesDataType | ExpectToRevokeDocUserRolesDataType | ExpectToUpdateDocUserRoleDataType | GraphqlBadRequestDataType | HttpRequestErrorDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType | InvalidIndexerInputDataType | InvalidLicenseToActivateDataType | InvalidLicenseUpdateParamsDataType | InvalidOauthCallbackCodeDataType | InvalidOauthResponseDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | InvalidSearchProviderRequestDataType | MemberNotFoundInSpaceDataType | MentionUserDocAccessDeniedDataType | MissingOauthQueryParameterDataType | NoMoreSeatDataType | NotInSpaceDataType | QueryTooLongDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType | SpaceShouldHaveOnlyOneOwnerDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | UnsupportedClientVersionDataType | UnsupportedSubscriptionPlanDataType | ValidationErrorDataType | VersionRejectedDataType | WorkspacePermissionNotFoundDataType | WrongSignInCredentialsDataType enum ErrorNames { ACCESS_DENIED @@ -601,6 +601,7 @@ enum ErrorNames { INVALID_LICENSE_UPDATE_PARAMS INVALID_OAUTH_CALLBACK_CODE INVALID_OAUTH_CALLBACK_STATE + INVALID_OAUTH_RESPONSE INVALID_PASSWORD_LENGTH INVALID_RUNTIME_CONFIG_TYPE INVALID_SEARCH_PROVIDER_REQUEST @@ -753,6 +754,10 @@ type InvalidOauthCallbackCodeDataType { status: Int! } +type InvalidOauthResponseDataType { + reason: String! +} + type InvalidPasswordLengthDataType { max: Int! min: Int! @@ -1266,6 +1271,7 @@ type NotificationWorkspaceType { } enum OAuthProviderType { + Apple GitHub Google OIDC diff --git a/packages/common/env/src/ua-helper.ts b/packages/common/env/src/ua-helper.ts index 9984bfb8a..e4b0833bd 100644 --- a/packages/common/env/src/ua-helper.ts +++ b/packages/common/env/src/ua-helper.ts @@ -70,7 +70,7 @@ const getUa = (navigator: Navigator) => { const tiktok = mobile && /aweme/i.test(ua); const weibo = mobile && /Weibo/i.test(ua); const safari = - ios && !chrome && !wx && !weibo && !tiktok && /Safari|Macintosh/i.test(ua); + !chrome && !wx && !weibo && !tiktok && /Safari|Macintosh/i.test(ua); const firefox = /Firefox/.test(ua); const win = /windows|win32|win64|wow32|wow64/.test(uas); const linux = /linux/.test(uas); diff --git a/packages/common/graphql/src/schema.ts b/packages/common/graphql/src/schema.ts index 37b53da66..78b723ca2 100644 --- a/packages/common/graphql/src/schema.ts +++ b/packages/common/graphql/src/schema.ts @@ -671,6 +671,7 @@ export type ErrorDataUnion = | InvalidLicenseToActivateDataType | InvalidLicenseUpdateParamsDataType | InvalidOauthCallbackCodeDataType + | InvalidOauthResponseDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | InvalidSearchProviderRequestDataType @@ -773,6 +774,7 @@ export enum ErrorNames { INVALID_LICENSE_UPDATE_PARAMS = 'INVALID_LICENSE_UPDATE_PARAMS', INVALID_OAUTH_CALLBACK_CODE = 'INVALID_OAUTH_CALLBACK_CODE', INVALID_OAUTH_CALLBACK_STATE = 'INVALID_OAUTH_CALLBACK_STATE', + INVALID_OAUTH_RESPONSE = 'INVALID_OAUTH_RESPONSE', INVALID_PASSWORD_LENGTH = 'INVALID_PASSWORD_LENGTH', INVALID_RUNTIME_CONFIG_TYPE = 'INVALID_RUNTIME_CONFIG_TYPE', INVALID_SEARCH_PROVIDER_REQUEST = 'INVALID_SEARCH_PROVIDER_REQUEST', @@ -935,6 +937,11 @@ export interface InvalidOauthCallbackCodeDataType { status: Scalars['Int']['output']; } +export interface InvalidOauthResponseDataType { + __typename?: 'InvalidOauthResponseDataType'; + reason: Scalars['String']['output']; +} + export interface InvalidPasswordLengthDataType { __typename?: 'InvalidPasswordLengthDataType'; max: Scalars['Int']['output']; @@ -1775,6 +1782,7 @@ export interface NotificationWorkspaceType { } export enum OAuthProviderType { + Apple = 'Apple', GitHub = 'GitHub', Google = 'Google', OIDC = 'OIDC', diff --git a/packages/frontend/admin/src/config.json b/packages/frontend/admin/src/config.json index dfa2ecf5d..dc65f58a8 100644 --- a/packages/frontend/admin/src/config.json +++ b/packages/frontend/admin/src/config.json @@ -315,7 +315,13 @@ }, "providers.oidc": { "type": "Object", - "desc": "OIDC OAuth provider config" + "desc": "OIDC OAuth provider config", + "link": "https://openid.net/specs/openid-connect-core-1_0.html" + }, + "providers.apple": { + "type": "Object", + "desc": "Apple OAuth provider config", + "link": "https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/implementing_sign_in_with_apple_in_your_app" } }, "payment": { diff --git a/packages/frontend/core/src/components/affine/auth/oauth.tsx b/packages/frontend/core/src/components/affine/auth/oauth.tsx index 0cc6cd1a9..0334439ba 100644 --- a/packages/frontend/core/src/components/affine/auth/oauth.tsx +++ b/packages/frontend/core/src/components/affine/auth/oauth.tsx @@ -3,13 +3,17 @@ import { notify } from '@affine/component/ui/notification'; import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; import { AuthService, ServerService } from '@affine/core/modules/cloud'; import { UrlService } from '@affine/core/modules/url'; -import { type UserFriendlyError } from '@affine/error'; +import { UserFriendlyError } from '@affine/error'; import { OAuthProviderType } from '@affine/graphql'; -import { useI18n } from '@affine/i18n'; import track from '@affine/track'; -import { GithubIcon, GoogleIcon, LockIcon } from '@blocksuite/icons/rc'; +import { + AppleIcon, + GithubIcon, + GoogleIcon, + LockIcon, +} from '@blocksuite/icons/rc'; import { useLiveData, useService } from '@toeverything/infra'; -import { type ReactElement, type SVGAttributes } from 'react'; +import { type ReactElement, type SVGAttributes, useCallback } from 'react'; const OAuthProviderMap: Record< OAuthProviderType, @@ -28,6 +32,10 @@ const OAuthProviderMap: Record< [OAuthProviderType.OIDC]: { icon: , }, + + [OAuthProviderType.Apple]: { + icon: , + }, }; export function OAuth({ redirectUrl }: { redirectUrl?: string }) { @@ -37,85 +45,80 @@ export function OAuth({ redirectUrl }: { redirectUrl?: string }) { const oauthProviders = useLiveData( serverService.server.config$.map(r => r?.oauthProviders) ); - const scheme = urlService.getClientScheme(); + const auth = useService(AuthService); + + const onContinue = useAsyncCallback( + async (provider: OAuthProviderType) => { + track.$.$.auth.signIn({ method: 'oauth', provider }); + + const open: () => Promise | void = BUILD_CONFIG.isNative + ? async () => { + try { + const scheme = urlService.getClientScheme(); + const options = await auth.oauthPreflight( + provider, + scheme ?? 'web' + ); + urlService.openPopupWindow(options.url); + } catch (e) { + notify.error(UserFriendlyError.fromAny(e)); + } + } + : () => { + const params = new URLSearchParams(); + + params.set('provider', provider); + + if (redirectUrl) { + params.set('redirect_uri', redirectUrl); + } + + const oauthUrl = + serverService.server.baseUrl + + `/oauth/login?${params.toString()}`; + + urlService.openPopupWindow(oauthUrl); + }; + + const ret = open(); + + if (ret instanceof Promise) { + await ret; + } + }, + [urlService, redirectUrl, serverService, auth] + ); if (!oauth) { return null; } - return oauthProviders?.map(provider => ( - { - urlService.openPopupWindow(url); - }} - /> - )); + return oauthProviders?.map(provider => { + return ( + + ); + }); } -function OAuthProvider({ - provider, - redirectUrl, - scheme, - popupWindow, -}: { +interface OauthProviderProps { provider: OAuthProviderType; - redirectUrl?: string; - scheme?: string; - popupWindow: (url: string) => void; -}) { - const serverService = useService(ServerService); - const auth = useService(AuthService); + onContinue: (provider: OAuthProviderType) => void; +} + +function OAuthProvider({ onContinue, provider }: OauthProviderProps) { const { icon } = OAuthProviderMap[provider]; - const t = useI18n(); - const onClick = useAsyncCallback(async () => { - if (scheme && BUILD_CONFIG.isNative) { - let oauthUrl = ''; - try { - oauthUrl = await auth.oauthPreflight(provider, scheme); - } catch (e) { - console.error(e); - const err = e as UserFriendlyError; - notify.error({ - title: t[`error.${err.name}`](err.data), - }); - return; - } - popupWindow(oauthUrl); - return; - } - - const params = new URLSearchParams(); - - params.set('provider', provider); - - if (redirectUrl) { - params.set('redirect_uri', redirectUrl); - } - - if (scheme) { - params.set('client', scheme); - } - - // TODO: Android app scheme not implemented - // if (BUILD_CONFIG.isAndroid) {} - - const oauthUrl = - serverService.server.baseUrl + `/oauth/login?${params.toString()}`; - - track.$.$.auth.signIn({ method: 'oauth', provider }); - - popupWindow(oauthUrl); - }, [popupWindow, provider, redirectUrl, scheme, serverService, auth, t]); + const onClick = useCallback(() => { + onContinue(provider); + }, [onContinue, provider]); return (