feat(server): add Apple sign-in (#2158)
This commit is contained in:
@@ -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`
|
||||
- Apple(optional;启用时以下四项必须一起配置)
|
||||
- `AUTH_APPLE_CLIENT_ID`
|
||||
- `AUTH_APPLE_TEAM_ID`
|
||||
- `AUTH_APPLE_KEY_ID`
|
||||
- `AUTH_APPLE_PRIVATE_KEY_PEM`
|
||||
|
||||
### Stripe
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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:*',
|
||||
|
||||
Reference in New Issue
Block a user