fix(server): support native Apple ID token sign-in (#2176)
This commit is contained in:
@@ -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`
|
||||
- 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`
|
||||
|
||||
@@ -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`,
|
||||
}),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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('')
|
||||
|
||||
Reference in New Issue
Block a user