mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-03-13 09:43:53 +08:00
feat(core): support apple sign in (#12424)
This commit is contained in:
@@ -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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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<OAuthProviderConfig>;
|
||||
[OAuthProviderName.GitHub]: ConfigItem<OAuthProviderConfig>;
|
||||
[OAuthProviderName.Apple]: ConfigItem<OAuthProviderConfig>;
|
||||
[OAuthProviderName.OIDC]: ConfigItem<OAuthOIDCProviderConfig>;
|
||||
};
|
||||
};
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
131
packages/backend/server/src/plugins/oauth/providers/apple.ts
Normal file
131
packages/backend/server/src/plugins/oauth/providers/apple.ts
Normal file
@@ -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<JwtPayload>((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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<Tokens>;
|
||||
abstract getUser(token: string): Promise<OAuthAccount>;
|
||||
abstract getUser(tokens: Tokens, state: any): Promise<OAuthAccount>;
|
||||
|
||||
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')
|
||||
|
||||
@@ -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}`,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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<typeof OIDCUserInfoSchema>;
|
||||
|
||||
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<typeof OIDCConfigurationSchema>;
|
||||
|
||||
const logger = new Logger('OIDCClient');
|
||||
|
||||
class OIDCClient {
|
||||
private static async fetch<T = any>(
|
||||
url: string,
|
||||
options: RequestInit,
|
||||
verifier: z.Schema<T>
|
||||
): Promise<T> {
|
||||
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<Tokens> {
|
||||
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<string, string>
|
||||
): OAuthAccount {
|
||||
const mappedUser: Partial<OAuthAccount> = {};
|
||||
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<Tokens> {
|
||||
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<OAuthAccount> {
|
||||
this.checkOIDCClient(this.client);
|
||||
return await this.client.userinfo(token);
|
||||
|
||||
async getUser(tokens: Tokens): Promise<OAuthAccount> {
|
||||
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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
2
packages/common/env/src/ua-helper.ts
vendored
2
packages/common/env/src/ua-helper.ts
vendored
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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: <LockIcon />,
|
||||
},
|
||||
|
||||
[OAuthProviderType.Apple]: {
|
||||
icon: <AppleIcon />,
|
||||
},
|
||||
};
|
||||
|
||||
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> | 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 => (
|
||||
<OAuthProvider
|
||||
key={provider}
|
||||
provider={provider}
|
||||
redirectUrl={redirectUrl}
|
||||
scheme={scheme}
|
||||
popupWindow={url => {
|
||||
urlService.openPopupWindow(url);
|
||||
}}
|
||||
/>
|
||||
));
|
||||
return oauthProviders?.map(provider => {
|
||||
return (
|
||||
<OAuthProvider
|
||||
key={provider}
|
||||
provider={provider}
|
||||
onContinue={onContinue}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
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 (
|
||||
<Button
|
||||
key={provider}
|
||||
variant="primary"
|
||||
variant={provider === OAuthProviderType.Apple ? 'custom' : 'primary'}
|
||||
block
|
||||
size="extraLarge"
|
||||
style={{ width: '100%' }}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type LoaderFunction,
|
||||
redirect,
|
||||
useLoaderData,
|
||||
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
|
||||
// oxlint-disable-next-line @typescript-eslint/no-restricted-imports
|
||||
useNavigate,
|
||||
} from 'react-router-dom';
|
||||
import { z } from 'zod';
|
||||
@@ -67,7 +67,7 @@ export const Component = () => {
|
||||
useEffect(() => {
|
||||
auth
|
||||
.oauthPreflight(data.provider, data.client, data.redirectUri)
|
||||
.then(url => {
|
||||
.then(({ url }) => {
|
||||
// this is the url of oauth provider auth page, can't navigate with react-router
|
||||
location.href = url;
|
||||
})
|
||||
|
||||
@@ -114,7 +114,7 @@ export class AuthService extends Service {
|
||||
provider: OAuthProviderType,
|
||||
client: string,
|
||||
/** @deprecated*/ redirectUrl?: string
|
||||
) {
|
||||
): Promise<Record<string, string>> {
|
||||
this.setClientNonce();
|
||||
try {
|
||||
const res = await this.fetchService.fetch('/api/oauth/preflight', {
|
||||
@@ -130,9 +130,7 @@ export class AuthService extends Service {
|
||||
},
|
||||
});
|
||||
|
||||
let { url } = await res.json();
|
||||
|
||||
return url as string;
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
track.$.$.auth.signInFail({
|
||||
method: 'oauth',
|
||||
|
||||
@@ -8150,6 +8150,12 @@ export function useAFFiNEI18N(): {
|
||||
* `The third-party account has already been connected to another user.`
|
||||
*/
|
||||
["error.OAUTH_ACCOUNT_ALREADY_CONNECTED"](): string;
|
||||
/**
|
||||
* `Invalid OAuth response: {{reason}}.`
|
||||
*/
|
||||
["error.INVALID_OAUTH_RESPONSE"](options: {
|
||||
readonly reason: string;
|
||||
}): string;
|
||||
/**
|
||||
* `An invalid email provided: {{email}}`
|
||||
*/
|
||||
|
||||
@@ -2035,6 +2035,7 @@
|
||||
"error.INVALID_AUTH_STATE": "Invalid auth state. You might start the auth progress from another device.",
|
||||
"error.MISSING_OAUTH_QUERY_PARAMETER": "Missing query parameter `{{name}}`.",
|
||||
"error.OAUTH_ACCOUNT_ALREADY_CONNECTED": "The third-party account has already been connected to another user.",
|
||||
"error.INVALID_OAUTH_RESPONSE": "Invalid OAuth response: {{reason}}.",
|
||||
"error.INVALID_EMAIL": "An invalid email provided: {{email}}",
|
||||
"error.INVALID_PASSWORD_LENGTH": "Password must be between {{min}} and {{max}} characters",
|
||||
"error.PASSWORD_REQUIRED": "Password is required.",
|
||||
|
||||
93
yarn.lock
93
yarn.lock
@@ -958,6 +958,7 @@ __metadata:
|
||||
"@types/express-serve-static-core": "npm:^5.0.6"
|
||||
"@types/graphql-upload": "npm:^17.0.0"
|
||||
"@types/http-errors": "npm:^2.0.4"
|
||||
"@types/jsonwebtoken": "npm:^9.0.9"
|
||||
"@types/lodash-es": "npm:^4.17.12"
|
||||
"@types/mixpanel": "npm:^2.14.9"
|
||||
"@types/mustache": "npm:^4.2.5"
|
||||
@@ -990,6 +991,7 @@ __metadata:
|
||||
http-errors: "npm:^2.0.0"
|
||||
ioredis: "npm:^5.4.1"
|
||||
is-mobile: "npm:^5.0.0"
|
||||
jsonwebtoken: "npm:^9.0.2"
|
||||
keyv: "npm:^5.2.2"
|
||||
lodash-es: "npm:^4.17.21"
|
||||
mixpanel: "npm:^0.18.0"
|
||||
@@ -15199,6 +15201,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/jsonwebtoken@npm:^9.0.9":
|
||||
version: 9.0.9
|
||||
resolution: "@types/jsonwebtoken@npm:9.0.9"
|
||||
dependencies:
|
||||
"@types/ms": "npm:*"
|
||||
"@types/node": "npm:*"
|
||||
checksum: 10/ef4dc05ae5ae78e3d2e20c364437e4afb788017cc80dd8a23a3eb17a3fcecb41e6abba254aba974d45a71307dd375aba4fda73cec358923aaaf8dff4667bea09
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/katex@npm:^0.16.0, @types/katex@npm:^0.16.7":
|
||||
version: 0.16.7
|
||||
resolution: "@types/katex@npm:0.16.7"
|
||||
@@ -24802,6 +24814,24 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jsonwebtoken@npm:^9.0.2":
|
||||
version: 9.0.2
|
||||
resolution: "jsonwebtoken@npm:9.0.2"
|
||||
dependencies:
|
||||
jws: "npm:^3.2.2"
|
||||
lodash.includes: "npm:^4.3.0"
|
||||
lodash.isboolean: "npm:^3.0.3"
|
||||
lodash.isinteger: "npm:^4.0.4"
|
||||
lodash.isnumber: "npm:^3.0.3"
|
||||
lodash.isplainobject: "npm:^4.0.6"
|
||||
lodash.isstring: "npm:^4.0.1"
|
||||
lodash.once: "npm:^4.0.0"
|
||||
ms: "npm:^2.1.1"
|
||||
semver: "npm:^7.5.4"
|
||||
checksum: 10/6e9b6d879cec2b27f2f3a88a0c0973edc7ba956a5d9356b2626c4fddfda969e34a3832deaf79c3e1c6c9a525bc2c4f2c2447fa477f8ac660f0017c31a59ae96b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jsx-ast-utils@npm:3.3.5, jsx-ast-utils@npm:^2.4.1 || ^3.0.0":
|
||||
version: 3.3.5
|
||||
resolution: "jsx-ast-utils@npm:3.3.5"
|
||||
@@ -24842,6 +24872,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jwa@npm:^1.4.1":
|
||||
version: 1.4.2
|
||||
resolution: "jwa@npm:1.4.2"
|
||||
dependencies:
|
||||
buffer-equal-constant-time: "npm:^1.0.1"
|
||||
ecdsa-sig-formatter: "npm:1.0.11"
|
||||
safe-buffer: "npm:^5.0.1"
|
||||
checksum: 10/a46c9ddbcc226d9e85e13ef96328c7d331abddd66b5a55ec44bcf4350464a6125385ac9c1e64faa0fae8d586d90a14d6b5e96c73f0388970a3918d5252efb0f3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jwa@npm:^2.0.0":
|
||||
version: 2.0.1
|
||||
resolution: "jwa@npm:2.0.1"
|
||||
@@ -24853,6 +24894,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jws@npm:^3.2.2":
|
||||
version: 3.2.2
|
||||
resolution: "jws@npm:3.2.2"
|
||||
dependencies:
|
||||
jwa: "npm:^1.4.1"
|
||||
safe-buffer: "npm:^5.0.1"
|
||||
checksum: 10/70b016974af8a76d25030c80a0097b24ed5b17a9cf10f43b163c11cb4eb248d5d04a3fe48c0d724d2884c32879d878ccad7be0663720f46b464f662f7ed778fe
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jws@npm:^4.0.0":
|
||||
version: 4.0.0
|
||||
resolution: "jws@npm:4.0.0"
|
||||
@@ -25380,6 +25431,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lodash.includes@npm:^4.3.0":
|
||||
version: 4.3.0
|
||||
resolution: "lodash.includes@npm:4.3.0"
|
||||
checksum: 10/45e0a7c7838c931732cbfede6327da321b2b10482d5063ed21c020fa72b09ca3a4aa3bda4073906ab3f436cf36eb85a52ea3f08b7bab1e0baca8235b0e08fe51
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lodash.isarguments@npm:^3.1.0":
|
||||
version: 3.1.0
|
||||
resolution: "lodash.isarguments@npm:3.1.0"
|
||||
@@ -25387,6 +25445,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lodash.isboolean@npm:^3.0.3":
|
||||
version: 3.0.3
|
||||
resolution: "lodash.isboolean@npm:3.0.3"
|
||||
checksum: 10/b70068b4a8b8837912b54052557b21fc4774174e3512ed3c5b94621e5aff5eb6c68089d0a386b7e801d679cd105d2e35417978a5e99071750aa2ed90bffd0250
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lodash.isequal@npm:^4.5.0":
|
||||
version: 4.5.0
|
||||
resolution: "lodash.isequal@npm:4.5.0"
|
||||
@@ -25394,6 +25459,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lodash.isinteger@npm:^4.0.4":
|
||||
version: 4.0.4
|
||||
resolution: "lodash.isinteger@npm:4.0.4"
|
||||
checksum: 10/c971f5a2d67384f429892715550c67bac9f285604a0dd79275fd19fef7717aec7f2a6a33d60769686e436ceb9771fd95fe7fcb68ad030fc907d568d5a3b65f70
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lodash.ismatch@npm:^4.4.0":
|
||||
version: 4.4.0
|
||||
resolution: "lodash.ismatch@npm:4.4.0"
|
||||
@@ -25401,6 +25473,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lodash.isnumber@npm:^3.0.3":
|
||||
version: 3.0.3
|
||||
resolution: "lodash.isnumber@npm:3.0.3"
|
||||
checksum: 10/913784275b565346255e6ae6a6e30b760a0da70abc29f3e1f409081585875105138cda4a429ff02577e1bc0a7ae2a90e0a3079a37f3a04c3d6c5aaa532f4cab2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lodash.isplainobject@npm:^4.0.6":
|
||||
version: 4.0.6
|
||||
resolution: "lodash.isplainobject@npm:4.0.6"
|
||||
@@ -25408,6 +25487,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lodash.isstring@npm:^4.0.1":
|
||||
version: 4.0.1
|
||||
resolution: "lodash.isstring@npm:4.0.1"
|
||||
checksum: 10/eaac87ae9636848af08021083d796e2eea3d02e80082ab8a9955309569cb3a463ce97fd281d7dc119e402b2e7d8c54a23914b15d2fc7fff56461511dc8937ba0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lodash.kebabcase@npm:^4.1.1":
|
||||
version: 4.1.1
|
||||
resolution: "lodash.kebabcase@npm:4.1.1"
|
||||
@@ -25443,6 +25529,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lodash.once@npm:^4.0.0":
|
||||
version: 4.1.1
|
||||
resolution: "lodash.once@npm:4.1.1"
|
||||
checksum: 10/202f2c8c3d45e401b148a96de228e50ea6951ee5a9315ca5e15733d5a07a6b1a02d9da1e7fdf6950679e17e8ca8f7190ec33cae47beb249b0c50019d753f38f3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lodash.snakecase@npm:^4.1.1":
|
||||
version: 4.1.1
|
||||
resolution: "lodash.snakecase@npm:4.1.1"
|
||||
|
||||
Reference in New Issue
Block a user