All files / realm/blong-access/adapter/db oidc.ts

87.27% Statements 96/110
28.57% Branches 4/14
100% Functions 3/3
87.27% Lines 96/110

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 1111x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 2x 2x 2x 2x 2x 2x 2x     2x 1x 1x 1x 1x 1x 1x 1x 1x 1x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x     2x 1x 1x 1x 1x 1x 1x 1x 1x 4x 4x 4x 4x 4x 4x     4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x     4x 4x     4x 4x 4x 4x 4x 4x 4x     4x 4x     4x 4x 4x  
import crypto from 'node:crypto';
 
export type IJwk = {
    kid?: string;
    alg?: string;
    kty?: string;
    n?: string;
    e?: string;
    use?: string;
};
 
/** Fields of an OIDC discovery document (`.well-known/openid-configuration`) used by the flow. */
export interface IOidcConfiguration {
    issuer?: string;
    authorization_endpoint?: string;
    token_endpoint?: string;
    userinfo_endpoint?: string;
    jwks_uri?: string;
}
 
/**
 * Fetch the OIDC discovery document for a provider (best-effort).
 *
 * Returns `undefined` when the provider has no discovery document or it cannot be
 * reached, letting callers fall back to hardcoded endpoints.  All endpoints come
 * back as absolute URLs per the OIDC spec.
 */
export async function discoverOidcConfiguration(
    discoveryUrl: string,
): Promise<IOidcConfiguration | undefined> {
    try {
        const res = await fetch(discoveryUrl, {headers: {accept: 'application/json'}});
        if (!res.ok) return undefined;
        return (await res.json()) as IOidcConfiguration;
    } catch {
        return undefined;
    }
}
 
/**
 * Fetch the OIDC UserInfo claims for an access token (best-effort).
 *
 * Returns `undefined` when the request fails so callers can fall back to the ID-token
 * claims.  The returned object contains standardized claims (sub, name, given_name,
 * family_name, email, email_verified, picture, ...).
 */
export async function fetchUserInfo(
    userinfoEndpoint: string,
    accessToken: string,
): Promise<Record<string, unknown> | undefined> {
    try {
        const res = await fetch(userinfoEndpoint, {
            headers: {authorization: `Bearer ${accessToken}`},
        });
        if (!res.ok) return undefined;
        return (await res.json()) as Record<string, unknown>;
    } catch {
        return undefined;
    }
}
 
/**
 * Verify a Google ID token (JWT) signature against a JWKS and return its
 * decoded claims.  Uses node:crypto only (RS256 — Google's standard signing
 * algorithm).  Throws on malformed tokens, unknown keys, bad signatures or
 * audience mismatch.
 */
export function verifyIdToken(
    token: string,
    jwks: {keys?: IJwk[]},
    audience: string,
): Record<string, unknown> {
        const [headerB64, payloadB64, signatureB64] = token.split('.');
        if (!headerB64 || !payloadB64 || !signatureB64) {
            throw new Error('Invalid ID token format');
        }
 
        const header = JSON.parse(Buffer.from(headerB64, 'base64url').toString('utf8')) as {
            kid?: string;
            alg?: string;
        };
        const payload = JSON.parse(
            Buffer.from(payloadB64, 'base64url').toString('utf8'),
        ) as Record<string, unknown>;
        const signature = Buffer.from(signatureB64, 'base64url');
        const data = Buffer.from(`${headerB64}.${payloadB64}`, 'utf8');
 
        if (header.alg !== 'RS256') {
            throw new Error(`Unsupported ID token algorithm: ${header.alg}`);
        }
        const key = (jwks?.keys ?? []).find(k => k.kid === header.kid);
        if (!key || key.kty !== 'RSA' || !key.n || !key.e) {
            throw new Error('No matching RSA signing key in JWKS');
        }
 
        const publicKey = crypto.createPublicKey({
            key: {kty: key.kty, n: key.n, e: key.e},
            format: 'jwk',
        });
        const verified = crypto.verify('sha256', data, publicKey, signature);
        if (!verified) {
            throw new Error('ID token signature verification failed');
        }
 
        if (payload.aud && String(payload.aud) !== audience) {
            throw new Error('ID token audience mismatch');
        }
 
        return payload;
}