feat(core): support apple sign in (#12424)

This commit is contained in:
liuyi
2025-05-23 15:27:27 +08:00
committed by GitHub
parent a96cd3eb0a
commit 41781902f6
22 changed files with 629 additions and 260 deletions

View File

@@ -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": ""
}
}
}
},

View File

@@ -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",

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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',

View File

@@ -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": {

View File

@@ -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%' }}

View File

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

View File

@@ -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',

View File

@@ -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}}`
*/

View File

@@ -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.",

View File

@@ -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"