feat(server): add Apple sign-in (#2158)

This commit is contained in:
RainbowBird
2026-07-29 18:06:07 +08:00
committed by GitHub
parent 742bb80ca5
commit c1ca39f1e6
8 changed files with 208 additions and 0 deletions
@@ -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)
@@ -53,6 +53,11 @@
- `AUTH_GOOGLE_CLIENT_SECRET`
- `AUTH_GITHUB_CLIENT_ID`
- `AUTH_GITHUB_CLIENT_SECRET`
- Appleoptional;启用时以下四项必须一起配置)
- `AUTH_APPLE_CLIENT_ID`
- `AUTH_APPLE_TEAM_ID`
- `AUTH_APPLE_KEY_ID`
- `AUTH_APPLE_PRIVATE_KEY_PEM`
### Stripe
+43
View File
@@ -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<Env, 'AUTH_APPLE_CLIENT_ID' | 'AUTH_APPLE_TEAM_ID' | 'AUTH_APPLE_KEY_ID' | 'AUTH_APPLE_PRIVATE_KEY_PEM'>,
) {
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: {
+12
View File
@@ -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
+110
View File
@@ -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', () => {
+32
View File
@@ -13,6 +13,10 @@ function baseEnv(): Record<string, string> {
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', () => {
+1
View File
@@ -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)
@@ -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:*',