From 828691890242e23569fc33fa9bfe82869c38d1af Mon Sep 17 00:00:00 2001 From: Konstantinos Kopanidis Date: Wed, 8 Jul 2026 16:25:23 +0300 Subject: [PATCH] feat(auth): support multiple Apple OAuth client credential sets Allow deployments to register additional Apple OAuth clients and select one at init via oauthClientId. Generic OAuth providers keep flat configuration. --- .../authentication/src/config/apple.config.ts | 44 +++++++ .../src/handlers/oauth2/OAuth2.ts | 44 +++++-- .../src/handlers/oauth2/apple/apple.ts | 111 ++++++++++-------- .../oauth2/interfaces/AppleProviderConfig.ts | 22 ++++ .../src/handlers/oauth2/utils/index.ts | 1 + .../oauth2/utils/resolveAppleOAuthClient.ts | 63 ++++++++++ 6 files changed, 222 insertions(+), 63 deletions(-) create mode 100644 modules/authentication/src/handlers/oauth2/utils/resolveAppleOAuthClient.ts diff --git a/modules/authentication/src/config/apple.config.ts b/modules/authentication/src/config/apple.config.ts index ea87dca73..59d0fee45 100644 --- a/modules/authentication/src/config/apple.config.ts +++ b/modules/authentication/src/config/apple.config.ts @@ -1,8 +1,52 @@ import { oauth2Schema } from '../constants/index.js'; +const appleOAuthClientSchema = { + id: { + doc: 'Unique identifier for this Apple OAuth client credential set', + format: 'String', + default: '', + }, + name: { + doc: 'Display name for this Apple OAuth client credential set', + format: 'String', + default: '', + optional: true, + }, + clientId: { + format: 'String', + default: '', + }, + privateKey: { + doc: 'The private key for this Apple OAuth client', + format: 'String', + default: '', + }, + teamId: { + doc: 'The team id for this Apple OAuth client', + format: 'String', + default: '', + }, + keyId: { + doc: 'The private key id for this Apple OAuth client', + format: 'String', + default: '', + }, + redirect_uri: { + format: 'String', + default: '', + optional: true, + }, +}; + export default { apple: { ...oauth2Schema, + clients: { + doc: 'Additional Apple OAuth client credential sets for multi-app support', + format: 'Array', + default: [], + children: appleOAuthClientSchema, + }, privateKey: { doc: 'The private key that is provided by apple developer console for a specific app', format: 'String', diff --git a/modules/authentication/src/handlers/oauth2/OAuth2.ts b/modules/authentication/src/handlers/oauth2/OAuth2.ts index 823de5c65..01f833fbf 100644 --- a/modules/authentication/src/handlers/oauth2/OAuth2.ts +++ b/modules/authentication/src/handlers/oauth2/OAuth2.ts @@ -75,6 +75,14 @@ export abstract class OAuth2< return (this.initialized = true); } + protected resolveOAuthClientId(_call: ParsedRouterRequest): string { + return this.settings.clientId; + } + + protected getOAuthStateExtras(_call: ParsedRouterRequest): Record { + return {}; + } + async initNative(call: ParsedRouterRequest): Promise { const scopes = call.request.params?.scopes ?? this.defaultScopes; const { anonymousUser } = call.request.context; @@ -82,7 +90,7 @@ export abstract class OAuth2< // returns part of regular redirect options for native usage const queryOptions: Partial = { - client_id: this.settings.clientId, + client_id: this.resolveOAuthClientId(call), redirect_uri: conduitUrl + this.settings.callbackUrl, scope: this.constructScopes(scopes), }; @@ -97,6 +105,7 @@ export abstract class OAuth2< expiresAt: new Date(Date.now() + 10 * 60 * 1000), customRedirectUri: call.request.params.redirectUri, anonymousUserId: anonymousUser?._id, + ...this.getOAuthStateExtras(call), }, }) .catch(err => { @@ -125,7 +134,7 @@ export abstract class OAuth2< .replace(/\//g, '_'); } const queryOptions: RedirectOptions = { - client_id: this.settings.clientId, + client_id: this.resolveOAuthClientId(call), redirect_uri: conduitUrl + this.settings.callbackUrl, response_type: this.settings.responseType, response_mode: this.settings.responseMode, @@ -150,6 +159,7 @@ export abstract class OAuth2< expiresAt: new Date(Date.now() + 10 * 60 * 1000), customRedirectUri: call.request.params.redirectUri, anonymousUserId: anonymousUser?._id, + ...this.getOAuthStateExtras(call), }, }) .catch(err => { @@ -360,12 +370,7 @@ export abstract class OAuth2< path: `/init/${this.providerName}`, description: `Begins ${this.capitalizeProvider()} authentication.`, action: ConduitRouteActions.GET, - queryParams: { - scopes: [ConduitString.Optional], - invitationToken: ConduitString.Optional, - captchaToken: ConduitString.Optional, - redirectUri: ConduitString.Optional, - }, + queryParams: this.getInitRouteQueryParams(), middlewares: initRouteMiddleware, rateLimit: OAUTH_INIT, }, @@ -381,11 +386,7 @@ export abstract class OAuth2< path: `/initNative/${this.providerName}`, description: `Begins ${this.capitalizeProvider()} native authentication.`, action: ConduitRouteActions.GET, - queryParams: { - scopes: [ConduitString.Optional], - invitationToken: ConduitString.Optional, - captchaToken: ConduitString.Optional, - }, + queryParams: this.getInitNativeRouteQueryParams(), middlewares: initRouteMiddleware, rateLimit: OAUTH_INIT, }, @@ -455,6 +456,23 @@ export abstract class OAuth2< } } + protected getInitRouteQueryParams() { + return { + scopes: [ConduitString.Optional], + invitationToken: ConduitString.Optional, + captchaToken: ConduitString.Optional, + redirectUri: ConduitString.Optional, + }; + } + + protected getInitNativeRouteQueryParams() { + return { + scopes: [ConduitString.Optional], + invitationToken: ConduitString.Optional, + captchaToken: ConduitString.Optional, + }; + } + makeRequest(data: AuthParams): OAuthRequest { return { method: this.settings.accessTokenMethod, diff --git a/modules/authentication/src/handlers/oauth2/apple/apple.ts b/modules/authentication/src/handlers/oauth2/apple/apple.ts index 611d54384..b4e7c5b25 100644 --- a/modules/authentication/src/handlers/oauth2/apple/apple.ts +++ b/modules/authentication/src/handlers/oauth2/apple/apple.ts @@ -9,6 +9,7 @@ import { } from '@conduitplatform/grpc-sdk'; import appleParameters from '../apple/apple.json' with { type: 'json' }; import { + AppleClientCredentials, AppleOAuth2Settings, AppleProviderConfig, ConnectionParams, @@ -22,8 +23,7 @@ import { Token } from '../../../models/index.js'; import { status } from '@grpc/grpc-js'; import moment from 'moment'; import jwksRsa from 'jwks-rsa'; - -import { validateStateToken } from '../utils/index.js'; +import { validateStateToken, resolveAppleOAuthClient } from '../utils/index.js'; import { ConduitJson, ConduitString, @@ -49,7 +49,8 @@ export class AppleHandlers extends OAuth2 { !authConfig['apple'] || !authConfig['apple'].clientId || !authConfig['apple'].privateKey || - !authConfig['apple'].teamId + !authConfig['apple'].teamId || + !authConfig['apple'].keyId ) { ConduitGrpcSdk.Logger.log(`Apple authentication not available`); return (this.initialized = false); @@ -66,9 +67,32 @@ export class AppleHandlers extends OAuth2 { return scopes.join(' '); } + protected getInitRouteQueryParams() { + return { + ...super.getInitRouteQueryParams(), + oauthClientId: ConduitString.Optional, + }; + } + + protected getInitNativeRouteQueryParams() { + return { + ...super.getInitNativeRouteQueryParams(), + oauthClientId: ConduitString.Optional, + }; + } + + protected resolveOAuthClientId(call: ParsedRouterRequest): string { + return resolveAppleOAuthClient(call.request.params?.oauthClientId).clientId; + } + + protected getOAuthStateExtras(call: ParsedRouterRequest): Record { + return { oauthClientId: call.request.params?.oauthClientId }; + } + async authorize(call: ParsedRouterRequest) { const params = call.request.params; const stateToken = await validateStateToken(params.state); + const providerClient = resolveAppleOAuthClient(stateToken.data.oauthClientId); const decoded_id_token = jwt.decode(params.id_token, { complete: true }); const publicKeys = await axios.get('https://appleid.apple.com/auth/keys'); @@ -76,33 +100,14 @@ export class AppleHandlers extends OAuth2 { (key: Indexable) => key.kid === decoded_id_token!.header.kid, ); const applePublicKey = await this.generateApplePublicKey(publicKey.kid); - this.verifyIdentityToken(applePublicKey, params.id_token); + this.verifyIdentityToken(applePublicKey, params.id_token, providerClient.clientId); - const apple_private_key = this.settings.privateKey; + const apple_client_secret = this.buildAppleClientSecret(providerClient); - const jwtHeader = { - alg: 'ES256', - kid: this.settings.keyId, - }; - - const jwtPayload = { - iss: this.settings.teamId, - iat: Math.floor(Date.now() / 1000), - exp: Math.floor(Date.now() / 1000) + 86400, - aud: 'https://appleid.apple.com', - sub: this.settings.clientId, - }; - - const apple_client_secret = jwt.sign(jwtPayload, apple_private_key, { - algorithm: 'ES256', - header: jwtHeader, - }); - - const clientId = this.settings.clientId; const conduitUrl = (await this.grpcSdk.config.get('router')).hostUrl; const config = ConfigController.getInstance().config; const tokenBody = new URLSearchParams(); - tokenBody.set('client_id', clientId); + tokenBody.set('client_id', providerClient.clientId); tokenBody.set('client_secret', apple_client_secret); tokenBody.set('code', params.code); if (this.settings.grantType) { @@ -148,7 +153,7 @@ export class AppleHandlers extends OAuth2 { const redirectUri = AuthUtils.validateRedirectUri(stateToken.data.customRedirectUri) ?? - this.settings.finalRedirect; + providerClient.redirect_uri; const conduitClientId = stateToken.data.clientId; return TokenProvider.getInstance()!.provideUserTokens( @@ -164,6 +169,7 @@ export class AppleHandlers extends OAuth2 { async nativeAuthorize(call: ParsedRouterRequest) { const params = call.request.bodyParams; const stateToken = await validateStateToken(params.state); + const providerClient = resolveAppleOAuthClient(stateToken.data.oauthClientId); const decoded_id_token = jwt.decode(params.id_token, { complete: true }); const publicKeys = await axios.get('https://appleid.apple.com/auth/keys'); @@ -171,32 +177,13 @@ export class AppleHandlers extends OAuth2 { (key: Indexable) => key.kid === decoded_id_token!.header.kid, ); const applePublicKey = await this.generateApplePublicKey(publicKey.kid); - this.verifyIdentityToken(applePublicKey, params.id_token); - - const apple_private_key = this.settings.privateKey; - - const jwtHeader = { - alg: 'ES256', - kid: this.settings.keyId, - }; - - const jwtPayload = { - iss: this.settings.teamId, - iat: Math.floor(Date.now() / 1000), - exp: Math.floor(Date.now() / 1000) + 86400, - aud: 'https://appleid.apple.com', - sub: this.settings.clientId, - }; + this.verifyIdentityToken(applePublicKey, params.id_token, providerClient.clientId); - const apple_client_secret = jwt.sign(jwtPayload, apple_private_key, { - algorithm: 'ES256', - header: jwtHeader, - }); + const apple_client_secret = this.buildAppleClientSecret(providerClient); - const clientId = this.settings.clientId; const config = ConfigController.getInstance().config; const nativeTokenBody = new URLSearchParams(); - nativeTokenBody.set('client_id', clientId); + nativeTokenBody.set('client_id', providerClient.clientId); nativeTokenBody.set('client_secret', apple_client_secret); nativeTokenBody.set('code', params.code); if (this.settings.grantType) { @@ -277,7 +264,31 @@ export class AppleHandlers extends OAuth2 { return key.getPublicKey(); } - private verifyIdentityToken(applePublicKey: string, id_token: string) { + private buildAppleClientSecret(providerClient: AppleClientCredentials): string { + const jwtHeader = { + alg: 'ES256', + kid: providerClient.keyId, + }; + + const jwtPayload = { + iss: providerClient.teamId, + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 86400, + aud: 'https://appleid.apple.com', + sub: providerClient.clientId, + }; + + return jwt.sign(jwtPayload, providerClient.privateKey, { + algorithm: 'ES256', + header: jwtHeader, + }); + } + + private verifyIdentityToken( + applePublicKey: string, + id_token: string, + expectedClientId: string, + ) { const decoded = jwt.decode(id_token, { complete: true }) as Jwt; const payload = decoded.payload as JwtPayload; const header = decoded.header as JwtHeader; @@ -291,7 +302,7 @@ export class AppleHandlers extends OAuth2 { if (payload.iss !== 'https://appleid.apple.com') { throw new GrpcError(status.INVALID_ARGUMENT, 'Invalid iss'); } - if (payload.aud !== this.settings.clientId) { + if (payload.aud !== expectedClientId) { throw new GrpcError(status.INVALID_ARGUMENT, 'Invalid aud'); } diff --git a/modules/authentication/src/handlers/oauth2/interfaces/AppleProviderConfig.ts b/modules/authentication/src/handlers/oauth2/interfaces/AppleProviderConfig.ts index fcf22e0b5..1607748ea 100644 --- a/modules/authentication/src/handlers/oauth2/interfaces/AppleProviderConfig.ts +++ b/modules/authentication/src/handlers/oauth2/interfaces/AppleProviderConfig.ts @@ -1,7 +1,29 @@ import { ProviderConfig } from './ProviderConfig.js'; +export interface AppleClientCredentials { + clientId: string; + privateKey: string; + teamId: string; + keyId: string; +} + +export interface AppleOAuthClientConfig { + id: string; + name?: string; + clientId: string; + privateKey?: string; + teamId?: string; + keyId?: string; + redirect_uri?: string; +} + +export interface ResolvedAppleOAuthClient extends AppleClientCredentials { + redirect_uri: string; +} + export interface AppleProviderConfig extends ProviderConfig { privateKey: string; teamId: string; keyId: string; + clients?: AppleOAuthClientConfig[]; } diff --git a/modules/authentication/src/handlers/oauth2/utils/index.ts b/modules/authentication/src/handlers/oauth2/utils/index.ts index 0bfd51d50..58f7ac885 100644 --- a/modules/authentication/src/handlers/oauth2/utils/index.ts +++ b/modules/authentication/src/handlers/oauth2/utils/index.ts @@ -1,2 +1,3 @@ export * from './ValidateStateToken.js'; export * from './MakeRequest.js'; +export * from './resolveAppleOAuthClient.js'; diff --git a/modules/authentication/src/handlers/oauth2/utils/resolveAppleOAuthClient.ts b/modules/authentication/src/handlers/oauth2/utils/resolveAppleOAuthClient.ts new file mode 100644 index 000000000..35b499e4c --- /dev/null +++ b/modules/authentication/src/handlers/oauth2/utils/resolveAppleOAuthClient.ts @@ -0,0 +1,63 @@ +import { GrpcError } from '@conduitplatform/grpc-sdk'; +import { status } from '@grpc/grpc-js'; +import { ConfigController } from '@conduitplatform/module-tools'; +import { + AppleOAuthClientConfig, + ResolvedAppleOAuthClient, +} from '../interfaces/AppleProviderConfig.js'; + +function findAppleClient( + clients: AppleOAuthClientConfig[] | undefined, + oauthClientId: string, +): AppleOAuthClientConfig { + const client = (clients ?? []).find(entry => entry.id === oauthClientId); + if (!client) { + throw new GrpcError( + status.INVALID_ARGUMENT, + `Unknown Apple OAuth client id: ${oauthClientId}`, + ); + } + return client; +} + +function assertRequiredCredential( + value: string | undefined, + oauthClientId: string, + field: string, +): asserts value is string { + if (!value) { + throw new GrpcError( + status.INVALID_ARGUMENT, + `Apple OAuth client '${oauthClientId}' is missing ${field}`, + ); + } +} + +export function resolveAppleOAuthClient( + oauthClientId?: string, +): ResolvedAppleOAuthClient { + const providerConfig = ConfigController.getInstance().config.apple; + if (!oauthClientId) { + return { + clientId: providerConfig.clientId, + redirect_uri: providerConfig.redirect_uri, + privateKey: providerConfig.privateKey, + teamId: providerConfig.teamId, + keyId: providerConfig.keyId, + }; + } + + const client = findAppleClient(providerConfig.clients, oauthClientId); + assertRequiredCredential(client.clientId, oauthClientId, 'clientId'); + assertRequiredCredential(client.privateKey, oauthClientId, 'privateKey'); + assertRequiredCredential(client.teamId, oauthClientId, 'teamId'); + assertRequiredCredential(client.keyId, oauthClientId, 'keyId'); + + return { + clientId: client.clientId, + redirect_uri: client.redirect_uri ?? providerConfig.redirect_uri, + privateKey: client.privateKey, + teamId: client.teamId, + keyId: client.keyId, + }; +}