199 lines
6.4 KiB
TypeScript
199 lines
6.4 KiB
TypeScript
import { randomToken, type OAuthProvider, type OAuthTokens } from "./index.ts";
|
|
import { verifyJwtWithJwks, type JwtClaims, type RemoteJwks } from "@wrnexus/jwt";
|
|
|
|
export interface OAuthStateRecord {
|
|
state: string;
|
|
verifier: string;
|
|
redirectUri: string;
|
|
returnTo?: string;
|
|
expiresAt: number;
|
|
}
|
|
export interface OAuthStateStore {
|
|
set(record: OAuthStateRecord): Promise<void>;
|
|
consume(state: string): Promise<OAuthStateRecord | null>;
|
|
}
|
|
export function memoryOAuthStateStore(now: () => number = Date.now): OAuthStateStore {
|
|
const records = new Map<string, OAuthStateRecord>();
|
|
return {
|
|
async set(record) {
|
|
records.set(record.state, record);
|
|
},
|
|
async consume(state) {
|
|
const value = records.get(state);
|
|
records.delete(state);
|
|
if (!value || value.expiresAt <= now()) return null;
|
|
return value;
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function createOAuthState(
|
|
store: OAuthStateStore,
|
|
input: Omit<OAuthStateRecord, "state" | "expiresAt"> & { ttlMs?: number },
|
|
): Promise<OAuthStateRecord> {
|
|
const record: OAuthStateRecord = {
|
|
state: randomToken(),
|
|
verifier: input.verifier,
|
|
redirectUri: input.redirectUri,
|
|
returnTo: input.returnTo,
|
|
expiresAt: Date.now() + (input.ttlMs ?? 10 * 60 * 1000),
|
|
};
|
|
await store.set(record);
|
|
return record;
|
|
}
|
|
|
|
export async function refreshOAuthTokens(
|
|
provider: OAuthProvider,
|
|
refreshToken: string,
|
|
fetchImpl: typeof fetch = fetch,
|
|
): Promise<OAuthTokens> {
|
|
const response = await fetchImpl(provider.tokenUrl, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
body: new URLSearchParams({
|
|
grant_type: "refresh_token",
|
|
refresh_token: refreshToken,
|
|
client_id: provider.clientId,
|
|
client_secret: provider.clientSecret,
|
|
}),
|
|
});
|
|
if (!response.ok) throw new Error(`${provider.name} token refresh failed (${response.status})`);
|
|
const tokens = (await response.json()) as OAuthTokens;
|
|
if (!tokens.refresh_token) tokens.refresh_token = refreshToken;
|
|
return tokens;
|
|
}
|
|
|
|
export interface OidcDiscovery {
|
|
issuer: string;
|
|
authorization_endpoint: string;
|
|
token_endpoint: string;
|
|
userinfo_endpoint?: string;
|
|
jwks_uri: string;
|
|
revocation_endpoint?: string;
|
|
}
|
|
|
|
function requireHttpsEndpoint(value: unknown, name: string): string {
|
|
if (typeof value !== "string") throw new Error(`OIDC discovery is missing ${name}`);
|
|
const url = new URL(value);
|
|
if (url.protocol !== "https:" || url.username || url.password || url.hash) {
|
|
throw new Error(`OIDC ${name} must be an HTTPS URL without credentials or a fragment`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export async function discoverOidc(
|
|
issuer: string,
|
|
fetchImpl: typeof fetch = fetch,
|
|
): Promise<OidcDiscovery> {
|
|
const base = issuer.replace(/\/$/, "");
|
|
const response = await fetchImpl(`${base}/.well-known/openid-configuration`);
|
|
if (!response.ok) throw new Error(`OIDC discovery failed (${response.status})`);
|
|
const value = (await response.json()) as Partial<OidcDiscovery>;
|
|
if (value.issuer !== base) throw new Error("OIDC issuer mismatch");
|
|
return {
|
|
issuer: base,
|
|
authorization_endpoint: requireHttpsEndpoint(
|
|
value.authorization_endpoint,
|
|
"authorization_endpoint",
|
|
),
|
|
token_endpoint: requireHttpsEndpoint(value.token_endpoint, "token_endpoint"),
|
|
jwks_uri: requireHttpsEndpoint(value.jwks_uri, "jwks_uri"),
|
|
userinfo_endpoint: value.userinfo_endpoint
|
|
? requireHttpsEndpoint(value.userinfo_endpoint, "userinfo_endpoint")
|
|
: undefined,
|
|
revocation_endpoint: value.revocation_endpoint
|
|
? requireHttpsEndpoint(value.revocation_endpoint, "revocation_endpoint")
|
|
: undefined,
|
|
};
|
|
}
|
|
|
|
export interface OidcIdTokenClaims extends JwtClaims {
|
|
sub: string;
|
|
iss: string;
|
|
aud: string | string[];
|
|
exp: number;
|
|
iat: number;
|
|
nonce?: string;
|
|
azp?: string;
|
|
at_hash?: string;
|
|
}
|
|
|
|
export interface VerifyOidcIdTokenOptions {
|
|
issuer: string;
|
|
clientId: string;
|
|
jwks: RemoteJwks;
|
|
nonce?: string;
|
|
accessToken?: string;
|
|
now?: number;
|
|
clockTolerance?: number;
|
|
maxAge?: number;
|
|
}
|
|
|
|
export function validateOidcClaims(
|
|
claims: JwtClaims,
|
|
options: Pick<VerifyOidcIdTokenOptions, "clientId" | "nonce">,
|
|
): asserts claims is OidcIdTokenClaims {
|
|
if (typeof claims.sub !== "string" || !claims.sub) throw new Error("OIDC token has no subject");
|
|
if (
|
|
typeof claims.iss !== "string" ||
|
|
(typeof claims.aud !== "string" && !Array.isArray(claims.aud)) ||
|
|
typeof claims.exp !== "number" ||
|
|
typeof claims.iat !== "number"
|
|
) {
|
|
throw new Error("OIDC token is missing required claims");
|
|
}
|
|
const audiences = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
|
|
if (audiences.length > 1 && claims.azp !== options.clientId)
|
|
throw new Error("OIDC token has invalid authorized party");
|
|
if (claims.azp !== undefined && claims.azp !== options.clientId)
|
|
throw new Error("OIDC token has invalid authorized party");
|
|
if (options.nonce !== undefined && claims.nonce !== options.nonce)
|
|
throw new Error("OIDC token has invalid nonce");
|
|
}
|
|
|
|
async function accessTokenHash(accessToken: string): Promise<string> {
|
|
const digest = new Uint8Array(
|
|
await crypto.subtle.digest("SHA-256", new TextEncoder().encode(accessToken)),
|
|
).slice(0, 16);
|
|
let binary = "";
|
|
for (const byte of digest) binary += String.fromCharCode(byte);
|
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
}
|
|
|
|
export async function verifyOidcIdToken(
|
|
token: string,
|
|
options: VerifyOidcIdTokenOptions,
|
|
): Promise<OidcIdTokenClaims> {
|
|
const issuer = options.issuer.replace(/\/$/, "");
|
|
const claims = await verifyJwtWithJwks(token, options.jwks, {
|
|
issuer,
|
|
audience: options.clientId,
|
|
now: options.now,
|
|
clockTolerance: options.clockTolerance,
|
|
maxAge: options.maxAge,
|
|
});
|
|
validateOidcClaims(claims, options);
|
|
if (options.accessToken !== undefined) {
|
|
if (typeof claims.at_hash !== "string") throw new Error("OIDC token has no access-token hash");
|
|
if ((await accessTokenHash(options.accessToken)) !== claims.at_hash)
|
|
throw new Error("OIDC token has invalid access-token hash");
|
|
}
|
|
return claims;
|
|
}
|
|
|
|
export function validateOAuthReturnTo(
|
|
value: string | undefined,
|
|
origin: string,
|
|
fallback = "/",
|
|
): string {
|
|
if (!value) return fallback;
|
|
try {
|
|
const url = new URL(value, origin);
|
|
return url.origin === new URL(origin).origin
|
|
? `${url.pathname}${url.search}${url.hash}`
|
|
: fallback;
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|