release: WRNexusJS 0.8.0
This commit is contained in:
@@ -194,3 +194,24 @@ const gitlab = defineProvider({
|
||||
`verifier` between `startAuth` and `completeAuth` (session or signed cookie).
|
||||
- Pairs with [`@wrnexus/core`](../core) — feed the normalized `OAuthProfile` into
|
||||
`logIn` to establish a session.
|
||||
OIDC integrations can combine strict discovery with the rotating JWKS verifier:
|
||||
|
||||
```ts
|
||||
import { createRemoteJwks } from "@wrnexus/jwt";
|
||||
import { discoverOidc, verifyOidcIdToken } from "@wrnexus/oauth";
|
||||
|
||||
const metadata = await discoverOidc("https://issuer.example");
|
||||
const jwks = createRemoteJwks(metadata.jwks_uri);
|
||||
const claims = await verifyOidcIdToken(idToken, {
|
||||
issuer: metadata.issuer,
|
||||
clientId: "client-id",
|
||||
jwks,
|
||||
nonce: expectedNonce,
|
||||
accessToken,
|
||||
});
|
||||
```
|
||||
|
||||
Discovery requires an exact normalized issuer and HTTPS endpoints without URL
|
||||
credentials/fragments. ID-token verification checks the RS256 signature,
|
||||
expiry/not-before, issuer, audience, required OIDC claims, nonce, multi-audience
|
||||
`azp`, optional token age, and optional `at_hash` binding.
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
{
|
||||
"name": "@wrnexus/oauth",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/jwt": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { randomToken, type OAuthProvider, type OAuthTokens } from "./index.ts";
|
||||
import { verifyJwtWithJwks, type JwtClaims, type RemoteJwks } from "@wrnexus/jwt";
|
||||
|
||||
export interface OAuthStateRecord {
|
||||
state: string;
|
||||
@@ -70,6 +71,16 @@ export interface OidcDiscovery {
|
||||
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,
|
||||
@@ -77,9 +88,97 @@ export async function discoverOidc(
|
||||
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 OidcDiscovery;
|
||||
if (value.issuer !== issuer && value.issuer !== base) throw new Error("OIDC issuer mismatch");
|
||||
return value;
|
||||
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(
|
||||
|
||||
@@ -246,6 +246,14 @@ export {
|
||||
createOAuthState,
|
||||
refreshOAuthTokens,
|
||||
discoverOidc,
|
||||
validateOidcClaims,
|
||||
verifyOidcIdToken,
|
||||
validateOAuthReturnTo,
|
||||
} from "./advanced.ts";
|
||||
export type { OAuthStateRecord, OAuthStateStore, OidcDiscovery } from "./advanced.ts";
|
||||
export type {
|
||||
OAuthStateRecord,
|
||||
OAuthStateStore,
|
||||
OidcDiscovery,
|
||||
OidcIdTokenClaims,
|
||||
VerifyOidcIdTokenOptions,
|
||||
} from "./advanced.ts";
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
exchangeCode,
|
||||
completeAuth,
|
||||
randomToken,
|
||||
discoverOidc,
|
||||
validateOidcClaims,
|
||||
} from "../src/index.ts";
|
||||
|
||||
const CREDS = { clientId: "cid", clientSecret: "secret" };
|
||||
@@ -93,3 +95,42 @@ test("completeAuth maps the provider profile (custom provider)", async () => {
|
||||
expect(profile.id).toBe("99");
|
||||
expect(profile.email).toBe("x@acme.test");
|
||||
});
|
||||
|
||||
test("OIDC discovery enforces issuer and secure required endpoints", async () => {
|
||||
const valid = await discoverOidc("https://issuer.example/", (async () =>
|
||||
Response.json({
|
||||
issuer: "https://issuer.example",
|
||||
authorization_endpoint: "https://issuer.example/authorize",
|
||||
token_endpoint: "https://issuer.example/token",
|
||||
jwks_uri: "https://keys.example/jwks",
|
||||
})) as unknown as typeof fetch);
|
||||
expect(valid.jwks_uri).toBe("https://keys.example/jwks");
|
||||
await expect(
|
||||
discoverOidc("https://issuer.example", (async () =>
|
||||
Response.json({
|
||||
issuer: "https://attacker.example",
|
||||
authorization_endpoint: "https://issuer.example/authorize",
|
||||
token_endpoint: "http://issuer.example/token",
|
||||
jwks_uri: "https://issuer.example/jwks",
|
||||
})) as unknown as typeof fetch),
|
||||
).rejects.toThrow("issuer mismatch");
|
||||
});
|
||||
|
||||
test("OIDC claim conformance enforces nonce, subject, and authorized party", () => {
|
||||
const valid = {
|
||||
sub: "user-1",
|
||||
iss: "https://issuer.example",
|
||||
aud: ["client-1", "api"],
|
||||
azp: "client-1",
|
||||
exp: 200,
|
||||
iat: 100,
|
||||
nonce: "nonce-1",
|
||||
};
|
||||
expect(() => validateOidcClaims(valid, { clientId: "client-1", nonce: "nonce-1" })).not.toThrow();
|
||||
expect(() =>
|
||||
validateOidcClaims({ ...valid, nonce: "wrong" }, { clientId: "client-1", nonce: "nonce-1" }),
|
||||
).toThrow("nonce");
|
||||
expect(() => validateOidcClaims({ ...valid, azp: "other" }, { clientId: "client-1" })).toThrow(
|
||||
"authorized party",
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user