/opt/canhelp/apps/api/src/lib
Edit: /opt/canhelp/apps/api/src/lib/apple-client-secret.ts (4073B)
import { createSign } from 'node:crypto'
import { readFileSync } from 'node:fs'
const APPLE_AUD = 'https://appleid.apple.com'
const DEFAULT_TTL_SECONDS = 60 * 60 * 24 * 170
const REFRESH_WINDOW_SECONDS = 60 * 60 * 24 * 7
function b64url(input: string | Buffer): string {
const buff = Buffer.isBuffer(input) ? input : Buffer.from(input)
return buff.toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_')
}
function parseJwtExp(token: string): number | null {
const parts = token.split('.')
if (parts.length < 2) return null
try {
const payload = JSON.parse(Buffer.from(parts[1].replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'))
return typeof payload?.exp === 'number' ? payload.exp : null
} catch {
return null
}
}
function shouldRefresh(token: string): boolean {
const exp = parseJwtExp(token)
if (!exp) return true
const now = Math.floor(Date.now() / 1000)
return exp - now <= REFRESH_WINDOW_SECONDS
}
function resolveApplePrivateKey(): string | null {
const inline = process.env.APPLE_PRIVATE_KEY?.trim()
if (inline) {
return inline.includes('\\n') ? inline.replace(/\\n/g, '\n') : inline
}
const keyPath = process.env.APPLE_PRIVATE_KEY_PATH?.trim()
if (!keyPath) return null
try {
return readFileSync(keyPath, 'utf8')
} catch {
return null
}
}
function createAppleClientSecret(params: {
clientId: string
teamId: string
keyId: string
privateKey: string
}): string {
const now = Math.floor(Date.now() / 1000)
const header = { alg: 'ES256', kid: params.keyId, typ: 'JWT' }
const payload = {
iss: params.teamId,
iat: now,
exp: now + DEFAULT_TTL_SECONDS,
aud: APPLE_AUD,
sub: params.clientId,
}
const encodedHeader = b64url(JSON.stringify(header))
const encodedPayload = b64url(JSON.stringify(payload))
const signingInput = `${encodedHeader}.${encodedPayload}`
const signer = createSign('SHA256')
signer.update(signingInput)
signer.end()
const signature = signer.sign({ key: params.privateKey, dsaEncoding: 'ieee-p1363' })
return `${signingInput}.${b64url(signature)}`
}
let cachedSecret: string | null = null
export function getAppleClientSecret(): string | null {
const clientId = process.env.APPLE_CLIENT_ID?.trim()
if (!clientId) return null
if (cachedSecret && !shouldRefresh(cachedSecret)) {
return cachedSecret
}
const teamId = process.env.APPLE_TEAM_ID?.trim()
const keyId = process.env.APPLE_KEY_ID?.trim()
const privateKey = resolveApplePrivateKey()
if (!teamId || !keyId || !privateKey) {
return null
}
try {
const generated = createAppleClientSecret({ clientId, teamId, keyId, privateKey })
cachedSecret = generated
return generated
} catch (err) {
console.error('[Auth] Apple client secret generation failed:', err)
return null
}
}
export function isAppleAuthConfigured(): boolean {
return Boolean(process.env.APPLE_CLIENT_ID?.trim() && getAppleClientSecret())
}
export function getAppleClientSecretExpDebugInfo(): {
configured: boolean
expUnix: number | null
expIso: string | null
expiresInSeconds: number | null
expiresInDays: number | null
refreshWindowSeconds: number
} {
const token = getAppleClientSecret()
if (!token) {
return {
configured: false,
expUnix: null,
expIso: null,
expiresInSeconds: null,
expiresInDays: null,
refreshWindowSeconds: REFRESH_WINDOW_SECONDS,
}
}
const expUnix = parseJwtExp(token)
if (!expUnix) {
return {
configured: true,
expUnix: null,
expIso: null,
expiresInSeconds: null,
expiresInDays: null,
refreshWindowSeconds: REFRESH_WINDOW_SECONDS,
}
}
const now = Math.floor(Date.now() / 1000)
const expiresInSeconds = Math.max(0, expUnix - now)
return {
configured: true,
expUnix,
expIso: new Date(expUnix * 1000).toISOString(),
expiresInSeconds,
expiresInDays: Number((expiresInSeconds / 86400).toFixed(2)),
refreshWindowSeconds: REFRESH_WINDOW_SECONDS,
}
}