mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-05 17:33:31 -04:00
145 lines
4.1 KiB
TypeScript
145 lines
4.1 KiB
TypeScript
import { encodeBase64url } from '@oslojs/encoding';
|
|
|
|
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: Bun.env.APPLE_ID!,
|
|
redirect_uri: `${Bun.env.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: Bun.env.APPLE_KEY_ID! };
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const payload = {
|
|
iss: Bun.env.APPLE_TEAM_ID!,
|
|
iat: now,
|
|
exp: now + 86400 * 180,
|
|
aud: 'https://appleid.apple.com',
|
|
sub: Bun.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 = Bun.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: Bun.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;
|
|
}
|