diff --git a/packages/backend/server/src/plugins/oauth/config.ts b/packages/backend/server/src/plugins/oauth/config.ts index 2e953ffb57..9a52a9a03d 100644 --- a/packages/backend/server/src/plugins/oauth/config.ts +++ b/packages/backend/server/src/plugins/oauth/config.ts @@ -4,7 +4,7 @@ import { defineModuleConfig, JSONSchema } from '../../base'; export interface OAuthProviderConfig { clientId: string; - clientSecret: string; + clientSecret?: string; args?: Record; } diff --git a/packages/backend/server/src/plugins/oauth/providers/apple.ts b/packages/backend/server/src/plugins/oauth/providers/apple.ts index 3d46df46b3..0d94a9cb45 100644 --- a/packages/backend/server/src/plugins/oauth/providers/apple.ts +++ b/packages/backend/server/src/plugins/oauth/providers/apple.ts @@ -2,6 +2,7 @@ import { JsonWebKey } from 'node:crypto'; import { Injectable } from '@nestjs/common'; import jwt, { type JwtPayload } from 'jsonwebtoken'; +import { z } from 'zod'; import { InternalServerError, @@ -19,14 +20,75 @@ interface AuthTokenResponse { expires_in: number; } +const AppleProviderArgsSchema = z.object({ + privateKey: z.string().nonempty(), + keyId: z.string().nonempty(), + teamId: z.string().nonempty(), +}); + @Injectable() export class AppleOAuthProvider extends OAuthProvider { provider = OAuthProviderName.Apple; + private args: z.infer | null = null; + private _jwtCache: { token: string; expiresAt: number } | null = null; constructor(private readonly url: URLHelper) { super(); } + override get configured() { + if (this.config && !this.args) { + const result = AppleProviderArgsSchema.safeParse(this.config?.args); + if (result.success) { + this.args = result.data; + } + } + + return ( + !!this.config && + !!this.config.clientId && + (!!this.config.clientSecret || !!this.args) + ); + } + + private get clientSecret() { + if (this.config.clientSecret) { + return this.config.clientSecret; + } + + if (!this.args) { + throw new Error('Missing Apple OAuth configuration'); + } + + if (this._jwtCache && this._jwtCache.expiresAt > Date.now()) { + return this._jwtCache.token; + } + + const { privateKey, keyId, teamId } = this.args; + const expiresIn = 300; // 5 minutes + + try { + const token = jwt.sign({}, privateKey, { + algorithm: 'ES256', + keyid: keyId, + expiresIn, + issuer: teamId, + audience: 'https://appleid.apple.com', + subject: this.config.clientId, + }); + + this._jwtCache = { + token, + expiresAt: Date.now() + (expiresIn - 30) * 1000, + }; + + return token; + } catch (e) { + this.logger.error('Failed to generate Apple client secret JWT', e); + throw new Error('Failed to generate client secret'); + } + } + getAuthUrl(state: string, clientNonce?: string): string { return `https://appleid.apple.com/auth/authorize?${this.url.stringify({ client_id: this.config.clientId, @@ -46,7 +108,7 @@ export class AppleOAuthProvider extends OAuthProvider { body: this.url.stringify({ code, client_id: this.config.clientId, - client_secret: this.config.clientSecret, + client_secret: this.clientSecret, redirect_uri: this.url.link('/api/oauth/callback'), grant_type: 'authorization_code', }),