diff --git a/apps/server/docs/ai-context/auth-and-oidc.md b/apps/server/docs/ai-context/auth-and-oidc.md index d5baf4317..3ae104876 100644 --- a/apps/server/docs/ai-context/auth-and-oidc.md +++ b/apps/server/docs/ai-context/auth-and-oidc.md @@ -72,6 +72,8 @@ 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 +# iOS 原生 Sign in with Apple;逗号分隔,每项必须与一个 Xcode target 的 Bundle ID 一致 +AUTH_APPLE_APP_BUNDLE_IDENTIFIERS=ai.moeru.airi-pocket,ai.moeru.airi-pro # OIDC Trusted Clients(均 optional,不配则不注册) # Web and Pocket are public clients (no secret, PKCE only) @@ -80,6 +82,53 @@ OIDC_CLIENT_ID_ELECTRON, OIDC_CLIENT_SECRET_ELECTRON OIDC_CLIENT_ID_POCKET ``` +### iOS 原生 Sign in with Apple + +iOS 使用 `ASAuthorizationAppleIDProvider` 获取 Apple identity token,然后直接调用 Better Auth 的社交登录接口;不要先请求授权 URL,也不需要打开系统浏览器: + +```http +POST /api/auth/sign-in/social +Content-Type: application/json + +{ + "provider": "apple", + "idToken": { + "token": "", + "nonce": "" + } +} +``` + +首次授权时,客户端可以额外传入 Apple 原生回调给出的姓名;email 由服务端从 identity token claim 读取: + +```json +{ + "provider": "apple", + "idToken": { + "token": "", + "nonce": "", + "user": { + "name": { + "firstName": "", + "lastName": "" + } + } + } +} +``` + +Better Auth 验证 token 的签名、issuer、Bundle ID audience allowlist 和可选 nonce 后,直接返回 session,不会返回 Apple 登录 URL: + +```json +{ + "redirect": false, + "token": "", + "user": {} +} +``` + +客户端后续可将返回的 session token 作为 `Authorization: Bearer ` 调用业务 API。Apple 只在首次授权提供姓名,并可能只在首次授权提供 email;服务端会保留首次写入的 email,后续 token 未携带 email 时使用 Apple `sub` 生成不可投递的 placeholder 以解析已绑定账号。 + ## Token 层次 | Token | 用途 | 存储位置 | 生命周期 | diff --git a/apps/server/docs/ai-context/workers-and-runtime.md b/apps/server/docs/ai-context/workers-and-runtime.md index 857c44c04..c20bc5f87 100644 --- a/apps/server/docs/ai-context/workers-and-runtime.md +++ b/apps/server/docs/ai-context/workers-and-runtime.md @@ -55,6 +55,7 @@ - `AUTH_GITHUB_CLIENT_SECRET` - Apple(optional;启用时以下四项必须一起配置) - `AUTH_APPLE_CLIENT_ID` +- `AUTH_APPLE_APP_BUNDLE_IDENTIFIERS`(iOS 原生 identity token 的逗号分隔 audience allowlist) - `AUTH_APPLE_TEAM_ID` - `AUTH_APPLE_KEY_ID` - `AUTH_APPLE_PRIVATE_KEY_PEM` diff --git a/apps/server/src/libs/auth.ts b/apps/server/src/libs/auth.ts index 309f3e81b..9e8bccebb 100644 --- a/apps/server/src/libs/auth.ts +++ b/apps/server/src/libs/auth.ts @@ -1,3 +1,5 @@ +import type { AppleProfile } from 'better-auth/social-providers' + import type { AuthMetrics } from '../otel' import type { EmailService } from '../services/adapters/email' import type { ProductEventService } from '../services/domain/product-events' @@ -100,11 +102,14 @@ function buildWebRedirectUris(env: Env): string[] { * 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. + * Go server's per-callback five-minute token. Apple's native + * AuthenticationServices API issues each app an ID token for its Bundle ID, + * so every first-party web/native identifier is kept in one explicit audience + * allowlist. Incomplete credentials leave the provider disabled, matching the + * empty optional configuration. */ function createAppleProviderConfig( - env: Pick, + env: Pick, ) { if (!env.AUTH_APPLE_CLIENT_ID || !env.AUTH_APPLE_TEAM_ID @@ -130,6 +135,23 @@ function createAppleProviderConfig( return { clientId: env.AUTH_APPLE_CLIENT_ID, clientSecret, + // Better Auth passes this array to jose's JWT audience check. Keeping + // the web Services ID in the same allowlist preserves web ID-token + // verification while allowing every configured native app Bundle ID. + audience: [ + env.AUTH_APPLE_CLIENT_ID, + ...env.AUTH_APPLE_APP_BUNDLE_IDENTIFIERS, + ], + // NOTICE: + // Why: Apple omits email after initial consent, while Better Auth 1.6.5 + // rejects ID-token sign-in before resolving the existing provider account. + // Root cause: `/api/routes/sign-in.mjs` requires userInfo.user.email. + // Source: `https://better-auth.com/docs/concepts/oauth#handling-providers-without-email`. + // Removal condition: Better Auth resolves existing accounts by + // providerId/accountId without requiring email (tracked upstream as #9124). + mapProfileToUser: (profile: AppleProfile) => ({ + email: profile.email || `${profile.sub}@apple.placeholder.local`, + }), } }, } diff --git a/apps/server/src/libs/env.ts b/apps/server/src/libs/env.ts index 5600a1412..364931f36 100644 --- a/apps/server/src/libs/env.ts +++ b/apps/server/src/libs/env.ts @@ -120,6 +120,18 @@ const EnvSchema = object({ 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_APP_BUNDLE_IDENTIFIERS: optional( + pipe( + string(), + transform(raw => [...new Set( + raw + .split(',') + .map(bundleIdentifier => bundleIdentifier.trim()) + .filter(Boolean), + )]), + ), + '', + ), AUTH_APPLE_TEAM_ID: optional(string(), ''), AUTH_APPLE_KEY_ID: optional(string(), ''), AUTH_APPLE_PRIVATE_KEY_PEM: optional( diff --git a/apps/server/src/libs/tests/auth.test.ts b/apps/server/src/libs/tests/auth.test.ts index eaeed023a..61954103a 100644 --- a/apps/server/src/libs/tests/auth.test.ts +++ b/apps/server/src/libs/tests/auth.test.ts @@ -48,6 +48,7 @@ describe('createAuth', () => { AUTH_GITHUB_CLIENT_ID: 'github-client', AUTH_GITHUB_CLIENT_SECRET: 'github-secret', AUTH_APPLE_CLIENT_ID: 'apple-service-id', + AUTH_APPLE_APP_BUNDLE_IDENTIFIERS: ['ai.moeru.airi-pocket', 'ai.moeru.airi-pro'], AUTH_APPLE_TEAM_ID: 'apple-team-id', AUTH_APPLE_KEY_ID: 'apple-key-id', AUTH_APPLE_PRIVATE_KEY_PEM: applePrivateKey, @@ -66,6 +67,7 @@ describe('createAuth', () => { AUTH_GITHUB_CLIENT_ID: 'github-client', AUTH_GITHUB_CLIENT_SECRET: 'github-secret', AUTH_APPLE_CLIENT_ID: 'apple-service-id', + AUTH_APPLE_APP_BUNDLE_IDENTIFIERS: ['ai.moeru.airi-pocket', 'ai.moeru.airi-pro'], AUTH_APPLE_TEAM_ID: 'apple-team-id', AUTH_APPLE_KEY_ID: 'apple-key-id', AUTH_APPLE_PRIVATE_KEY_PEM: applePrivateKey, @@ -85,6 +87,7 @@ describe('createAuth', () => { AUTH_GITHUB_CLIENT_ID: 'github-client', AUTH_GITHUB_CLIENT_SECRET: 'github-secret', AUTH_APPLE_CLIENT_ID: '', + AUTH_APPLE_APP_BUNDLE_IDENTIFIERS: [], AUTH_APPLE_TEAM_ID: '', AUTH_APPLE_KEY_ID: '', AUTH_APPLE_PRIVATE_KEY_PEM: '', @@ -103,6 +106,7 @@ describe('createAuth', () => { AUTH_GITHUB_CLIENT_ID: 'github-client', AUTH_GITHUB_CLIENT_SECRET: 'github-secret', AUTH_APPLE_CLIENT_ID: 'apple-service-id', + AUTH_APPLE_APP_BUNDLE_IDENTIFIERS: ['ai.moeru.airi-pocket', 'ai.moeru.airi-pro'], AUTH_APPLE_TEAM_ID: 'apple-team-id', AUTH_APPLE_KEY_ID: 'apple-key-id', AUTH_APPLE_PRIVATE_KEY_PEM: '', @@ -113,7 +117,7 @@ describe('createAuth', () => { expect(auth.options.socialProviders?.apple).toBeUndefined() }) - it('configures Apple with a verifiable ES256 client secret and trusted callback origin', async () => { + it('configures Apple for web OAuth and native ID-token sign-in', async () => { const auth = createAuth({} as unknown as Database, { API_SERVER_URL: 'http://localhost:3000', AUTH_GOOGLE_CLIENT_ID: 'google-client', @@ -121,6 +125,7 @@ describe('createAuth', () => { AUTH_GITHUB_CLIENT_ID: 'github-client', AUTH_GITHUB_CLIENT_SECRET: 'github-secret', AUTH_APPLE_CLIENT_ID: 'apple-service-id', + AUTH_APPLE_APP_BUNDLE_IDENTIFIERS: ['ai.moeru.airi-pocket', 'ai.moeru.airi-pro'], AUTH_APPLE_TEAM_ID: 'apple-team-id', AUTH_APPLE_KEY_ID: 'apple-key-id', AUTH_APPLE_PRIVATE_KEY_PEM: applePrivateKey, @@ -145,14 +150,49 @@ describe('createAuth', () => { audience: 'https://appleid.apple.com', })).resolves.toBeDefined() expect(config.clientId).toBe('apple-service-id') + expect(config.audience).toEqual([ + 'apple-service-id', + 'ai.moeru.airi-pocket', + 'ai.moeru.airi-pro', + ]) expect(header).toMatchObject({ alg: 'ES256', kid: 'apple-key-id' }) expect(claims.exp! - claims.iat!).toBe(180 * 24 * 60 * 60) + expect(await config.mapProfileToUser?.({ + sub: 'apple-user-id', + email: '', + email_verified: true, + is_private_email: false, + real_user_status: 2, + name: '', + picture: '', + })).toEqual({ + email: 'apple-user-id@apple.placeholder.local', + }) + expect(await config.mapProfileToUser?.({ + sub: 'apple-user-id', + email: 'relay@privaterelay.appleid.com', + email_verified: true, + is_private_email: true, + real_user_status: 2, + name: '', + picture: '', + })).toEqual({ + email: 'relay@privaterelay.appleid.com', + }) 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') + expect(resolvedProvider.options && 'audience' in resolvedProvider.options + ? resolvedProvider.options.audience + : undefined).toEqual([ + 'apple-service-id', + 'ai.moeru.airi-pocket', + 'ai.moeru.airi-pro', + ]) + const authorizationURL = await resolvedProvider.createAuthorizationURL({ state: 'apple-oauth-state', codeVerifier: 'unused-by-apple', diff --git a/apps/server/src/libs/tests/env.test.ts b/apps/server/src/libs/tests/env.test.ts index 2864bfc6f..67a135a0d 100644 --- a/apps/server/src/libs/tests/env.test.ts +++ b/apps/server/src/libs/tests/env.test.ts @@ -14,6 +14,7 @@ function baseEnv(): Record { AUTH_GITHUB_CLIENT_ID: 'github-client', AUTH_GITHUB_CLIENT_SECRET: 'github-secret', AUTH_APPLE_CLIENT_ID: 'apple-service-id', + AUTH_APPLE_APP_BUNDLE_IDENTIFIERS: 'ai.moeru.airi-pocket, ai.moeru.airi-pro, ai.moeru.airi-pocket', AUTH_APPLE_TEAM_ID: 'apple-team-id', AUTH_APPLE_KEY_ID: 'apple-key-id', AUTH_APPLE_PRIVATE_KEY_PEM: 'line-one\\nline-two', @@ -48,12 +49,17 @@ 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_APP_BUNDLE_IDENTIFIERS).toEqual([ + 'ai.moeru.airi-pocket', + 'ai.moeru.airi-pro', + ]) 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_APP_BUNDLE_IDENTIFIERS delete input.AUTH_APPLE_TEAM_ID delete input.AUTH_APPLE_KEY_ID delete input.AUTH_APPLE_PRIVATE_KEY_PEM @@ -61,6 +67,7 @@ describe('parseEnv', () => { const env = parseEnv(input) expect(env.AUTH_APPLE_CLIENT_ID).toBe('') + expect(env.AUTH_APPLE_APP_BUNDLE_IDENTIFIERS).toEqual([]) expect(env.AUTH_APPLE_TEAM_ID).toBe('') expect(env.AUTH_APPLE_KEY_ID).toBe('') expect(env.AUTH_APPLE_PRIVATE_KEY_PEM).toBe('')