From c1ca39f1e683819f9595b78cf656e289e1af84a7 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Wed, 29 Jul 2026 18:06:07 +0800 Subject: [PATCH] feat(server): add Apple sign-in (#2158) --- apps/server/docs/ai-context/auth-and-oidc.md | 3 + .../docs/ai-context/workers-and-runtime.md | 5 + apps/server/src/libs/auth.ts | 43 +++++++ apps/server/src/libs/env.ts | 12 ++ apps/server/src/libs/tests/auth.test.ts | 110 ++++++++++++++++++ apps/server/src/libs/tests/env.test.ts | 32 +++++ apps/server/src/utils/origin.ts | 1 + apps/server/src/utils/tests/origin.test.ts | 2 + 8 files changed, 208 insertions(+) diff --git a/apps/server/docs/ai-context/auth-and-oidc.md b/apps/server/docs/ai-context/auth-and-oidc.md index ea9a6b0c4..d5baf4317 100644 --- a/apps/server/docs/ai-context/auth-and-oidc.md +++ b/apps/server/docs/ai-context/auth-and-oidc.md @@ -69,6 +69,9 @@ Server 通过 `better-auth` 同时充当**用户认证后端**和 **OIDC Provide # 社交 Provider AUTH_GOOGLE_CLIENT_ID, AUTH_GOOGLE_CLIENT_SECRET AUTH_GITHUB_CLIENT_ID, AUTH_GITHUB_CLIENT_SECRET +# Apple optional;启用时四项必须一起配置 +AUTH_APPLE_CLIENT_ID, AUTH_APPLE_TEAM_ID +AUTH_APPLE_KEY_ID, AUTH_APPLE_PRIVATE_KEY_PEM # OIDC Trusted Clients(均 optional,不配则不注册) # Web and Pocket are public clients (no secret, PKCE only) diff --git a/apps/server/docs/ai-context/workers-and-runtime.md b/apps/server/docs/ai-context/workers-and-runtime.md index cd626f1c6..857c44c04 100644 --- a/apps/server/docs/ai-context/workers-and-runtime.md +++ b/apps/server/docs/ai-context/workers-and-runtime.md @@ -53,6 +53,11 @@ - `AUTH_GOOGLE_CLIENT_SECRET` - `AUTH_GITHUB_CLIENT_ID` - `AUTH_GITHUB_CLIENT_SECRET` +- Apple(optional;启用时以下四项必须一起配置) +- `AUTH_APPLE_CLIENT_ID` +- `AUTH_APPLE_TEAM_ID` +- `AUTH_APPLE_KEY_ID` +- `AUTH_APPLE_PRIVATE_KEY_PEM` ### Stripe diff --git a/apps/server/src/libs/auth.ts b/apps/server/src/libs/auth.ts index 50c899397..309f3e81b 100644 --- a/apps/server/src/libs/auth.ts +++ b/apps/server/src/libs/auth.ts @@ -15,6 +15,7 @@ import { createAuthMiddleware } from 'better-auth/api' import { deleteSessionCookie } from 'better-auth/cookies' import { admin, bearer, jwt, magicLink } from 'better-auth/plugins' import { eq } from 'drizzle-orm' +import { importPKCS8, SignJWT } from 'jose' import { ApiError } from '../utils/error' import { getAuthTrustedOrigins, getTrustedOrigin } from '../utils/origin' @@ -93,6 +94,47 @@ function buildWebRedirectUris(env: Env): string[] { return [...uris] } +/** + * Builds the optional Apple social-provider entry consumed by Better Auth. + * + * Apple uses a signed ES256 JWT as the OAuth client secret. Better Auth + * resolves async social-provider configuration once while creating its auth + * context, so this uses Apple's supported 180-day lifetime instead of the + * Go server's per-callback five-minute token. Incomplete credentials leave the + * provider disabled, matching the empty optional configuration. + */ +function createAppleProviderConfig( + env: Pick, +) { + if (!env.AUTH_APPLE_CLIENT_ID + || !env.AUTH_APPLE_TEAM_ID + || !env.AUTH_APPLE_KEY_ID + || !env.AUTH_APPLE_PRIVATE_KEY_PEM) { + return {} + } + + return { + apple: async () => { + const key = await importPKCS8(env.AUTH_APPLE_PRIVATE_KEY_PEM, 'ES256') + const issuedAt = Math.floor(Date.now() / 1000) + const clientSecret = await new SignJWT({}) + .setProtectedHeader({ alg: 'ES256', kid: env.AUTH_APPLE_KEY_ID }) + .setIssuer(env.AUTH_APPLE_TEAM_ID) + .setSubject(env.AUTH_APPLE_CLIENT_ID) + .setAudience('https://appleid.apple.com') + .setIssuedAt(issuedAt) + // Apple caps client-secret JWT validity at six months. + .setExpirationTime(issuedAt + 180 * 24 * 60 * 60) + .sign(key) + + return { + clientId: env.AUTH_APPLE_CLIENT_ID, + clientSecret, + } + }, + } +} + function buildTrustedWebRedirectUri(redirectUri: string, additionalTrustedOrigins: readonly string[]): string | null { try { const parsed = new URL(redirectUri) @@ -631,6 +673,7 @@ export function createAuth( // lookup. mapProfileToUser: () => ({ emailVerified: true }), }, + ...createAppleProviderConfig(env), }, hooks: { diff --git a/apps/server/src/libs/env.ts b/apps/server/src/libs/env.ts index 381d26d42..5600a1412 100644 --- a/apps/server/src/libs/env.ts +++ b/apps/server/src/libs/env.ts @@ -119,6 +119,18 @@ const EnvSchema = object({ AUTH_GOOGLE_CLIENT_SECRET: pipe(string(), nonEmpty('AUTH_GOOGLE_CLIENT_SECRET is required')), AUTH_GITHUB_CLIENT_ID: pipe(string(), nonEmpty('AUTH_GITHUB_CLIENT_ID is required')), AUTH_GITHUB_CLIENT_SECRET: pipe(string(), nonEmpty('AUTH_GITHUB_CLIENT_SECRET is required')), + AUTH_APPLE_CLIENT_ID: optional(string(), ''), + AUTH_APPLE_TEAM_ID: optional(string(), ''), + AUTH_APPLE_KEY_ID: optional(string(), ''), + AUTH_APPLE_PRIVATE_KEY_PEM: optional( + pipe( + string(), + // Deployment dashboards commonly store multiline secrets with escaped + // newlines. jose's PKCS8 importer requires the original PEM layout. + transform(raw => raw.replaceAll(String.raw`\n`, '\n')), + ), + '', + ), // Testing-only bearer token bypass. Keep unset in production. When set, // Authorization: Bearer $TEST_AUTH_TOKEN resolves to the virtual user below diff --git a/apps/server/src/libs/tests/auth.test.ts b/apps/server/src/libs/tests/auth.test.ts index 16859b8a7..eaeed023a 100644 --- a/apps/server/src/libs/tests/auth.test.ts +++ b/apps/server/src/libs/tests/auth.test.ts @@ -1,6 +1,9 @@ import type { Database } from '../db' import type { Env } from '../env' +import { generateKeyPairSync } from 'node:crypto' + +import { decodeJwt, decodeProtectedHeader, importSPKI, jwtVerify } from 'jose' import { describe, expect, it, vi } from 'vitest' import { createAuth, ensureDynamicFirstPartyRedirectUri, seedTrustedClients } from '../auth' @@ -33,6 +36,10 @@ function createMockDb(existingRowsByCall: unknown[][] = []) { } describe('createAuth', () => { + const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' }) + const applePrivateKey = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString() + const applePublicKey = publicKey.export({ type: 'spki', format: 'pem' }).toString() + it('allows signed-in users to link OAuth accounts that use a different email', () => { const auth = createAuth({} as unknown as Database, { API_SERVER_URL: 'http://localhost:3000', @@ -40,6 +47,10 @@ describe('createAuth', () => { AUTH_GOOGLE_CLIENT_SECRET: 'google-secret', AUTH_GITHUB_CLIENT_ID: 'github-client', AUTH_GITHUB_CLIENT_SECRET: 'github-secret', + AUTH_APPLE_CLIENT_ID: 'apple-service-id', + AUTH_APPLE_TEAM_ID: 'apple-team-id', + AUTH_APPLE_KEY_ID: 'apple-key-id', + AUTH_APPLE_PRIVATE_KEY_PEM: applePrivateKey, BETTER_AUTH_SECRET: 'test-secret-test-secret-test-secret', ADDITIONAL_TRUSTED_ORIGINS: [], } as unknown as Env) @@ -54,6 +65,10 @@ describe('createAuth', () => { AUTH_GOOGLE_CLIENT_SECRET: 'google-secret', AUTH_GITHUB_CLIENT_ID: 'github-client', AUTH_GITHUB_CLIENT_SECRET: 'github-secret', + AUTH_APPLE_CLIENT_ID: 'apple-service-id', + AUTH_APPLE_TEAM_ID: 'apple-team-id', + AUTH_APPLE_KEY_ID: 'apple-key-id', + AUTH_APPLE_PRIVATE_KEY_PEM: applePrivateKey, BETTER_AUTH_SECRET: 'test-secret-test-secret-test-secret', ADDITIONAL_TRUSTED_ORIGINS: [], } as unknown as Env) @@ -61,6 +76,101 @@ describe('createAuth', () => { expect(auth.options.socialProviders?.google?.prompt).toBe('select_account') expect(auth.options.socialProviders?.github?.prompt).toBe('select_account') }) + + it('does not register Apple when its optional credentials are absent', () => { + const auth = createAuth({} as unknown as Database, { + API_SERVER_URL: 'http://localhost:3000', + AUTH_GOOGLE_CLIENT_ID: 'google-client', + AUTH_GOOGLE_CLIENT_SECRET: 'google-secret', + AUTH_GITHUB_CLIENT_ID: 'github-client', + AUTH_GITHUB_CLIENT_SECRET: 'github-secret', + AUTH_APPLE_CLIENT_ID: '', + AUTH_APPLE_TEAM_ID: '', + AUTH_APPLE_KEY_ID: '', + AUTH_APPLE_PRIVATE_KEY_PEM: '', + BETTER_AUTH_SECRET: 'test-secret-test-secret-test-secret', + ADDITIONAL_TRUSTED_ORIGINS: [], + } as unknown as Env) + + expect(auth.options.socialProviders?.apple).toBeUndefined() + }) + + it('does not register Apple when its optional credentials are incomplete', () => { + const auth = createAuth({} as unknown as Database, { + API_SERVER_URL: 'http://localhost:3000', + AUTH_GOOGLE_CLIENT_ID: 'google-client', + AUTH_GOOGLE_CLIENT_SECRET: 'google-secret', + AUTH_GITHUB_CLIENT_ID: 'github-client', + AUTH_GITHUB_CLIENT_SECRET: 'github-secret', + AUTH_APPLE_CLIENT_ID: 'apple-service-id', + AUTH_APPLE_TEAM_ID: 'apple-team-id', + AUTH_APPLE_KEY_ID: 'apple-key-id', + AUTH_APPLE_PRIVATE_KEY_PEM: '', + BETTER_AUTH_SECRET: 'test-secret-test-secret-test-secret', + ADDITIONAL_TRUSTED_ORIGINS: [], + } as unknown as Env) + + expect(auth.options.socialProviders?.apple).toBeUndefined() + }) + + it('configures Apple with a verifiable ES256 client secret and trusted callback origin', async () => { + const auth = createAuth({} as unknown as Database, { + API_SERVER_URL: 'http://localhost:3000', + AUTH_GOOGLE_CLIENT_ID: 'google-client', + AUTH_GOOGLE_CLIENT_SECRET: 'google-secret', + AUTH_GITHUB_CLIENT_ID: 'github-client', + AUTH_GITHUB_CLIENT_SECRET: 'github-secret', + AUTH_APPLE_CLIENT_ID: 'apple-service-id', + AUTH_APPLE_TEAM_ID: 'apple-team-id', + AUTH_APPLE_KEY_ID: 'apple-key-id', + AUTH_APPLE_PRIVATE_KEY_PEM: applePrivateKey, + BETTER_AUTH_SECRET: 'test-secret-test-secret-test-secret', + ADDITIONAL_TRUSTED_ORIGINS: [], + } as unknown as Env) + + const appleProvider = auth.options.socialProviders?.apple + expect(typeof appleProvider).toBe('function') + if (typeof appleProvider !== 'function') + throw new TypeError('Expected Apple provider to use async configuration') + + const config = await appleProvider() + const header = decodeProtectedHeader(config.clientSecret) + const claims = decodeJwt(config.clientSecret) + const verificationKey = await importSPKI(applePublicKey, 'ES256') + + await expect(jwtVerify(config.clientSecret, verificationKey, { + algorithms: ['ES256'], + issuer: 'apple-team-id', + subject: 'apple-service-id', + audience: 'https://appleid.apple.com', + })).resolves.toBeDefined() + expect(config.clientId).toBe('apple-service-id') + expect(header).toMatchObject({ alg: 'ES256', kid: 'apple-key-id' }) + expect(claims.exp! - claims.iat!).toBe(180 * 24 * 60 * 60) + + const context = await auth.$context + const resolvedProvider = context.socialProviders.find(provider => provider.id === 'apple') + if (!resolvedProvider) + throw new TypeError('Expected Better Auth to resolve the Apple provider') + + const authorizationURL = await resolvedProvider.createAuthorizationURL({ + state: 'apple-oauth-state', + codeVerifier: 'unused-by-apple', + redirectURI: 'https://api.airi.build/api/auth/callback/apple', + }) + expect(authorizationURL.origin).toBe('https://appleid.apple.com') + expect(authorizationURL.pathname).toBe('/auth/authorize') + expect(authorizationURL.searchParams.get('client_id')).toBe('apple-service-id') + expect(authorizationURL.searchParams.get('redirect_uri')).toBe('https://api.airi.build/api/auth/callback/apple') + expect(authorizationURL.searchParams.get('scope')).toBe('email name') + expect(authorizationURL.searchParams.get('response_mode')).toBe('form_post') + + const trustedOrigins = auth.options.trustedOrigins + expect(typeof trustedOrigins).toBe('function') + if (typeof trustedOrigins !== 'function') + throw new TypeError('Expected request-aware trusted origins') + expect(await trustedOrigins(new Request('http://localhost:3000/api/auth/sign-in/social'))).toContain('https://appleid.apple.com') + }) }) describe('seedTrustedClients', () => { diff --git a/apps/server/src/libs/tests/env.test.ts b/apps/server/src/libs/tests/env.test.ts index 4bf4a0252..2864bfc6f 100644 --- a/apps/server/src/libs/tests/env.test.ts +++ b/apps/server/src/libs/tests/env.test.ts @@ -13,6 +13,10 @@ function baseEnv(): Record { AUTH_GOOGLE_CLIENT_SECRET: 'google-secret', AUTH_GITHUB_CLIENT_ID: 'github-client', AUTH_GITHUB_CLIENT_SECRET: 'github-secret', + AUTH_APPLE_CLIENT_ID: 'apple-service-id', + AUTH_APPLE_TEAM_ID: 'apple-team-id', + AUTH_APPLE_KEY_ID: 'apple-key-id', + AUTH_APPLE_PRIVATE_KEY_PEM: 'line-one\\nline-two', // Required: a deterministic 32-byte base64 value so env parse succeeds. LLM_ROUTER_MASTER_KEY: Buffer.alloc(32, 0xAA).toString('base64'), } @@ -44,6 +48,34 @@ describe('parseEnv', () => { expect(env.AUTH_UI_URL).toBe('https://accounts.airi.build/ui') expect(env.ADMIN_UI_URL).toBe('https://admin.airi.build') expect(env.ADDITIONAL_TRUSTED_ORIGINS).toEqual([]) + expect(env.AUTH_APPLE_PRIVATE_KEY_PEM).toBe('line-one\nline-two') + }) + + it('allows Apple auth to remain disabled when no Apple credentials are configured', () => { + const input = baseEnv() + delete input.AUTH_APPLE_CLIENT_ID + delete input.AUTH_APPLE_TEAM_ID + delete input.AUTH_APPLE_KEY_ID + delete input.AUTH_APPLE_PRIVATE_KEY_PEM + + const env = parseEnv(input) + + expect(env.AUTH_APPLE_CLIENT_ID).toBe('') + expect(env.AUTH_APPLE_TEAM_ID).toBe('') + expect(env.AUTH_APPLE_KEY_ID).toBe('') + expect(env.AUTH_APPLE_PRIVATE_KEY_PEM).toBe('') + }) + + it('leaves incomplete Apple credentials for provider setup to disable', () => { + const input = baseEnv() + delete input.AUTH_APPLE_PRIVATE_KEY_PEM + + const env = parseEnv(input) + + expect(env.AUTH_APPLE_CLIENT_ID).toBe('apple-service-id') + expect(env.AUTH_APPLE_TEAM_ID).toBe('apple-team-id') + expect(env.AUTH_APPLE_KEY_ID).toBe('apple-key-id') + expect(env.AUTH_APPLE_PRIVATE_KEY_PEM).toBe('') }) it('parses ADDITIONAL_TRUSTED_ORIGINS into a normalized origin list', () => { diff --git a/apps/server/src/utils/origin.ts b/apps/server/src/utils/origin.ts index 88247fec5..ed0d0933f 100644 --- a/apps/server/src/utils/origin.ts +++ b/apps/server/src/utils/origin.ts @@ -161,6 +161,7 @@ export function getAuthTrustedOrigins( for (const origin of TRUSTED_AUTH_CALLBACK_ORIGINS) { origins.add(origin) } + origins.add('https://appleid.apple.com') for (const origin of env.ADDITIONAL_TRUSTED_ORIGINS) { origins.add(origin) diff --git a/apps/server/src/utils/tests/origin.test.ts b/apps/server/src/utils/tests/origin.test.ts index e752b984b..c1fb432a9 100644 --- a/apps/server/src/utils/tests/origin.test.ts +++ b/apps/server/src/utils/tests/origin.test.ts @@ -72,6 +72,7 @@ describe('origin utils', () => { 'https://server-dev.airi-server-auth.pages.dev', 'https://admin.airi.build', 'https://server-dev.airi-server-admin.pages.dev', + 'https://appleid.apple.com', 'http://localhost:*', 'http://127.0.0.1:*', 'http://localhost:5173', @@ -132,6 +133,7 @@ describe('origin utils', () => { 'https://server-dev.airi-server-auth.pages.dev', 'https://admin.airi.build', 'https://server-dev.airi-server-admin.pages.dev', + 'https://appleid.apple.com', 'https://10.0.0.129:5273', 'http://localhost:*', 'http://127.0.0.1:*',