fix(server): support native Apple ID token sign-in (#2176)

This commit is contained in:
RainbowBird
2026-07-30 19:02:10 +08:00
committed by GitHub
parent 204197b8b4
commit 269de5b9e1
6 changed files with 135 additions and 4 deletions
@@ -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": "<ASAuthorizationAppleIDCredential.identityToken UTF-8 JWT>",
"nonce": "<the exact value assigned to ASAuthorizationAppleIDRequest.nonce>"
}
}
```
首次授权时,客户端可以额外传入 Apple 原生回调给出的姓名;email 由服务端从 identity token claim 读取:
```json
{
"provider": "apple",
"idToken": {
"token": "<identity-token>",
"nonce": "<nonce>",
"user": {
"name": {
"firstName": "<given-name>",
"lastName": "<family-name>"
}
}
}
}
```
Better Auth 验证 token 的签名、issuer、Bundle ID audience allowlist 和可选 nonce 后,直接返回 session,不会返回 Apple 登录 URL
```json
{
"redirect": false,
"token": "<better-auth-session-token>",
"user": {}
}
```
客户端后续可将返回的 session token 作为 `Authorization: Bearer <token>` 调用业务 API。Apple 只在首次授权提供姓名,并可能只在首次授权提供 email;服务端会保留首次写入的 email,后续 token 未携带 email 时使用 Apple `sub` 生成不可投递的 placeholder 以解析已绑定账号。
## Token 层次
| Token | 用途 | 存储位置 | 生命周期 |
@@ -55,6 +55,7 @@
- `AUTH_GITHUB_CLIENT_SECRET`
- Appleoptional;启用时以下四项必须一起配置)
- `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`
+25 -3
View File
@@ -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, 'AUTH_APPLE_CLIENT_ID' | 'AUTH_APPLE_TEAM_ID' | 'AUTH_APPLE_KEY_ID' | 'AUTH_APPLE_PRIVATE_KEY_PEM'>,
env: Pick<Env, 'AUTH_APPLE_CLIENT_ID' | 'AUTH_APPLE_APP_BUNDLE_IDENTIFIERS' | 'AUTH_APPLE_TEAM_ID' | 'AUTH_APPLE_KEY_ID' | 'AUTH_APPLE_PRIVATE_KEY_PEM'>,
) {
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`,
}),
}
},
}
+12
View File
@@ -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(
+41 -1
View File
@@ -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',
+7
View File
@@ -14,6 +14,7 @@ function baseEnv(): Record<string, string> {
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('')