mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-06 01:43:32 -04:00
Added Sign In with Apple
This commit is contained in:
146
src/lib/server/apple-auth.ts
Normal file
146
src/lib/server/apple-auth.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { encodeBase64url } from '@oslojs/encoding';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { env as publicEnv } from '$env/dynamic/public';
|
||||
|
||||
const APPLE_AUTH_URL = 'https://appleid.apple.com/auth/authorize';
|
||||
const APPLE_TOKEN_URL = 'https://appleid.apple.com/auth/token';
|
||||
|
||||
export function getAppleAuthUrl(state: string): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: env.APPLE_ID!,
|
||||
redirect_uri: `${publicEnv.PUBLIC_SITE_URL}/auth/apple/callback`,
|
||||
response_type: 'code',
|
||||
response_mode: 'form_post',
|
||||
scope: 'name email',
|
||||
state
|
||||
});
|
||||
return `${APPLE_AUTH_URL}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export async function generateAppleClientSecret(): Promise<string> {
|
||||
const header = { alg: 'ES256', kid: env.APPLE_KEY_ID! };
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = {
|
||||
iss: env.APPLE_TEAM_ID!,
|
||||
iat: now,
|
||||
exp: now + 86400 * 180,
|
||||
aud: 'https://appleid.apple.com',
|
||||
sub: env.APPLE_ID!
|
||||
};
|
||||
|
||||
const encodedHeader = encodeBase64url(new TextEncoder().encode(JSON.stringify(header)));
|
||||
const encodedPayload = encodeBase64url(new TextEncoder().encode(JSON.stringify(payload)));
|
||||
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
||||
|
||||
// Import PEM private key
|
||||
const pemBody = env.APPLE_PRIVATE_KEY!.replace(/-----BEGIN PRIVATE KEY-----/, '')
|
||||
.replace(/-----END PRIVATE KEY-----/, '')
|
||||
.replace(/\s/g, '');
|
||||
const keyBuffer = Uint8Array.from(atob(pemBody), (c) => c.charCodeAt(0));
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'pkcs8',
|
||||
keyBuffer,
|
||||
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
|
||||
const signatureBuffer = await crypto.subtle.sign(
|
||||
{ name: 'ECDSA', hash: 'SHA-256' },
|
||||
key,
|
||||
new TextEncoder().encode(signingInput)
|
||||
);
|
||||
|
||||
const signature = new Uint8Array(signatureBuffer);
|
||||
|
||||
// crypto.subtle may return DER or raw (IEEE P1363) format depending on runtime
|
||||
// Raw format is exactly 64 bytes (32-byte r + 32-byte s)
|
||||
const rawSignature = signature.length === 64 ? signature : derToRaw(signature);
|
||||
const encodedSignature = encodeBase64url(rawSignature);
|
||||
|
||||
return `${signingInput}.${encodedSignature}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a DER-encoded ECDSA signature to raw r||s format (64 bytes for P-256)
|
||||
*/
|
||||
function derToRaw(der: Uint8Array): Uint8Array {
|
||||
// DER structure: 0x30 [total-len] 0x02 [r-len] [r] 0x02 [s-len] [s]
|
||||
let offset = 2; // skip 0x30 and total length
|
||||
|
||||
// Read r
|
||||
if (der[offset] !== 0x02) throw new Error('Invalid DER signature');
|
||||
offset++;
|
||||
const rLen = der[offset];
|
||||
offset++;
|
||||
let r = der.slice(offset, offset + rLen);
|
||||
offset += rLen;
|
||||
|
||||
// Read s
|
||||
if (der[offset] !== 0x02) throw new Error('Invalid DER signature');
|
||||
offset++;
|
||||
const sLen = der[offset];
|
||||
offset++;
|
||||
let s = der.slice(offset, offset + sLen);
|
||||
|
||||
// Remove leading zero padding (DER uses it for positive sign)
|
||||
if (r.length === 33 && r[0] === 0) r = r.slice(1);
|
||||
if (s.length === 33 && s[0] === 0) s = s.slice(1);
|
||||
|
||||
// Pad to 32 bytes each
|
||||
const raw = new Uint8Array(64);
|
||||
raw.set(r, 32 - r.length);
|
||||
raw.set(s, 64 - s.length);
|
||||
return raw;
|
||||
}
|
||||
|
||||
export async function exchangeAppleCode(
|
||||
code: string,
|
||||
redirectUri: string
|
||||
): Promise<{
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
refresh_token: string;
|
||||
id_token: string;
|
||||
}> {
|
||||
const clientSecret = await generateAppleClientSecret();
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: env.APPLE_ID!,
|
||||
client_secret: clientSecret,
|
||||
code,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: redirectUri
|
||||
});
|
||||
|
||||
const response = await fetch(APPLE_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params.toString()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Apple token exchange failed: ${error}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode Apple's id_token JWT payload without signature verification.
|
||||
* Safe because the token is received directly from Apple's token endpoint over TLS.
|
||||
*/
|
||||
export function decodeAppleIdToken(idToken: string): {
|
||||
sub: string;
|
||||
email?: string;
|
||||
email_verified?: string;
|
||||
is_private_email?: string;
|
||||
} {
|
||||
const [, payloadB64] = idToken.split('.');
|
||||
const padded = payloadB64 + '='.repeat((4 - (payloadB64.length % 4)) % 4);
|
||||
const payload = JSON.parse(atob(padded.replace(/-/g, '+').replace(/_/g, '/')));
|
||||
return payload;
|
||||
}
|
||||
Reference in New Issue
Block a user