From the logic audit: - consent rejects sid-less bearers and requires a LIVE hub session (sessionAlive) before minting codes — a logged-out bearer can no longer mint downstream tokens inside its TTL - refresh rotation race closed: consume returns whether THIS call won; the loser revokes the family (reuse detection now holds under concurrency) - error redirects carry iss (RFC 9207) on error responses too - logout awaits the revocation and no longer returns ok for missing or forged bearers
434 lines
22 KiB
TypeScript
434 lines
22 KiB
TypeScript
import { createHash, createPublicKey, randomBytes, verify } from "node:crypto";
|
|
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { BadRequestException, ForbiddenException, HttpException, UnauthorizedException } from "@nestjs/common";
|
|
import { Vault, type SealedCredential } from "../vault/vault.js";
|
|
import { CODE_TTL_SECONDS, DEFAULT_ISSUER, OidcConfigError, WEB_REDIRECT_URI, loadOidcConfig, validateAuthorize, type OidcConfig } from "./oidc.config.js";
|
|
import { FileKeyStore, OidcKeyError, OidcKeyring, type KeySealer, type PublicJwk } from "./oidc.keys.js";
|
|
import { OidcService, type OidcUser, type OidcUserDirectory, type TokenResponse } from "./oidc.service.js";
|
|
import { MemoryRefreshStore } from "./oidc.refresh-store.js";
|
|
|
|
const WORKSPACE = "11111111-1111-4111-8111-111111111111";
|
|
const ADA: OidcUser = { id: "22222222-2222-4222-8222-222222222222", username: "ada.l", email: "ada@example.org", displayName: "Ada Lovelace" };
|
|
|
|
function pkce(): { verifier: string; challenge: string } {
|
|
const verifier = randomBytes(32).toString("base64url");
|
|
return { verifier, challenge: createHash("sha256").update(verifier).digest("base64url") };
|
|
}
|
|
|
|
function authorizeParams(challenge: string, extra: Record<string, string> = {}): Record<string, string> {
|
|
return {
|
|
response_type: "code",
|
|
client_id: "web",
|
|
redirect_uri: WEB_REDIRECT_URI,
|
|
scope: "openid profile email",
|
|
state: "st-123",
|
|
nonce: "n-456",
|
|
code_challenge: challenge,
|
|
code_challenge_method: "S256",
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
function decode(part: string): Record<string, unknown> {
|
|
return JSON.parse(Buffer.from(part, "base64url").toString("utf8")) as Record<string, unknown>;
|
|
}
|
|
|
|
/** Independent ES256 check against the published JWKS (what oCIS does). */
|
|
function verifiedBy(jwks: { keys: PublicJwk[] }, token: string): Record<string, unknown> {
|
|
const [header, payload, signature] = token.split(".") as [string, string, string];
|
|
const jwk = jwks.keys.find((k) => k.kid === decode(header).kid);
|
|
if (jwk === undefined) throw new Error("kid not in JWKS");
|
|
const key = createPublicKey({ key: { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y }, format: "jwk" });
|
|
const ok = verify("sha256", Buffer.from(`${header}.${payload}`), { key, dsaEncoding: "ieee-p1363" }, Buffer.from(signature, "base64url"));
|
|
if (!ok) throw new Error("signature did not verify");
|
|
return decode(payload);
|
|
}
|
|
|
|
class FakeUsers implements OidcUserDirectory {
|
|
readonly members = new Map<string, OidcUser>([[ADA.id, ADA]]);
|
|
async activeMember(workspaceId: string, userId: string): Promise<OidcUser | null> {
|
|
return workspaceId === WORKSPACE ? (this.members.get(userId) ?? null) : null;
|
|
}
|
|
}
|
|
|
|
/** Distinct KEK → the sealed keyset must not open. */
|
|
class OtherKekSealer implements KeySealer {
|
|
async seal(): Promise<SealedCredential> {
|
|
throw new Error("unused");
|
|
}
|
|
async open(): Promise<Buffer> {
|
|
throw new Error("unsupported state or unable to authenticate data");
|
|
}
|
|
}
|
|
|
|
let keyDir: string;
|
|
beforeEach(async () => {
|
|
keyDir = await mkdtemp(join(tmpdir(), "hub-oidc-"));
|
|
});
|
|
afterEach(async () => {
|
|
await rm(keyDir, { recursive: true, force: true });
|
|
});
|
|
|
|
function harness(): { service: OidcService; users: FakeUsers; clock: { now: number }; config: OidcConfig; store: MemoryRefreshStore; sessions: Set<string> } {
|
|
const users = new FakeUsers();
|
|
const clock = { now: 1_800_000_000_000 };
|
|
const config: OidcConfig = { issuer: DEFAULT_ISSUER, consentUrl: new URL("https://app.occupymedia.cc/oidc/consent"), keyDir };
|
|
const vault = new Vault();
|
|
const store = new MemoryRefreshStore();
|
|
const sessions = store.liveSessions;
|
|
store.liveSessions.add(`${WORKSPACE}:test-sid`);
|
|
store.liveSessions.add(`${WORKSPACE}:test-sid`);
|
|
const service = new OidcService({
|
|
config: () => config,
|
|
keyring: (c) => OidcKeyring.open(new FileKeyStore(c.keyDir), vault),
|
|
users,
|
|
now: () => clock.now,
|
|
reservedUsernames: () => new Set(["admin", "svc-files"]),
|
|
refreshStore: store,
|
|
});
|
|
return { service, users, clock, config, store, sessions };
|
|
}
|
|
|
|
async function issueCode(service: OidcService, challenge: string, extra: Record<string, string> = {}): Promise<string> {
|
|
const { redirectTo } = await service.consent(WORKSPACE, ADA.id, authorizeParams(challenge, extra), "test-sid");
|
|
const url = new URL(redirectTo);
|
|
const code = url.searchParams.get("code");
|
|
if (code === null) throw new Error(`no code in redirect: ${url.searchParams.get("error") ?? ""}`);
|
|
return code;
|
|
}
|
|
|
|
function exchange(service: OidcService, code: string, verifier: string | undefined, extra: Record<string, string> = {}): Promise<TokenResponse> {
|
|
const body: Record<string, string> = { grant_type: "authorization_code", client_id: "web", redirect_uri: WEB_REDIRECT_URI, code, ...extra };
|
|
if (verifier !== undefined) body.code_verifier = verifier;
|
|
return service.token(body);
|
|
}
|
|
|
|
async function oauthError(promise: Promise<unknown>): Promise<{ status: number; error: string }> {
|
|
const err = await promise.then(() => null, (e: unknown) => e);
|
|
if (!(err instanceof HttpException)) throw new Error("expected an HttpException");
|
|
const body = err.getResponse() as { error?: string };
|
|
return { status: err.getStatus(), error: body.error ?? "" };
|
|
}
|
|
|
|
describe("loadOidcConfig", () => {
|
|
it("defaults to the production issuer and consent page", () => {
|
|
const config = loadOidcConfig({ OIDC_KEY_DIR: "/var/lib/hub/oidc" });
|
|
expect(config.issuer).toBe("https://api.occupymedia.cc/oidc");
|
|
expect(config.consentUrl.toString()).toBe("https://app.occupymedia.cc/oidc/consent");
|
|
});
|
|
|
|
it("requires a key volume in production and refuses non-https issuers", () => {
|
|
expect(() => loadOidcConfig({ NODE_ENV: "production" })).toThrow(OidcConfigError);
|
|
expect(() => loadOidcConfig({ OIDC_KEY_DIR: "/k", OIDC_ISSUER: "http://api.example.org/oidc" })).toThrow(OidcConfigError);
|
|
expect(loadOidcConfig({ OIDC_KEY_DIR: "/k", OIDC_ISSUER: "http://localhost:3000/oidc/" }).issuer).toBe("http://localhost:3000/oidc");
|
|
});
|
|
});
|
|
|
|
describe("validateAuthorize", () => {
|
|
const { challenge } = pkce();
|
|
|
|
it("never redirects to an unknown client or an unregistered redirect_uri", () => {
|
|
expect(validateAuthorize(authorizeParams(challenge, { client_id: "evil" }))).toMatchObject({ ok: false, redirect: false, error: "invalid_client" });
|
|
for (const redirect_uri of [`${WEB_REDIRECT_URI}?x=1`, "https://files.occupymedia.cc/oidc-callback.html/", "https://evil.example/oidc-callback.html"]) {
|
|
expect(validateAuthorize(authorizeParams(challenge, { redirect_uri }))).toMatchObject({ ok: false, redirect: false });
|
|
}
|
|
});
|
|
|
|
it("requires PKCE S256 (missing challenge or plain method is rejected)", () => {
|
|
const missing = authorizeParams(challenge);
|
|
delete missing.code_challenge;
|
|
expect(validateAuthorize(missing)).toMatchObject({ ok: false, redirect: true, error: "invalid_request", state: "st-123" });
|
|
expect(validateAuthorize(authorizeParams(challenge, { code_challenge_method: "plain" }))).toMatchObject({ ok: false, redirect: true });
|
|
expect(validateAuthorize(authorizeParams("too-short", {}))).toMatchObject({ ok: false, redirect: true });
|
|
});
|
|
|
|
it("requires openid and refuses unknown scopes", () => {
|
|
expect(validateAuthorize(authorizeParams(challenge, { scope: "profile email" }))).toMatchObject({ ok: false, error: "invalid_scope" });
|
|
expect(validateAuthorize(authorizeParams(challenge, { scope: "openid offline_access" }))).toMatchObject({ ok: false, error: "invalid_scope" });
|
|
expect(validateAuthorize(authorizeParams(challenge))).toMatchObject({ ok: true, request: { scopes: ["openid", "profile", "email"] } });
|
|
});
|
|
});
|
|
|
|
describe("OidcKeyring persistence", () => {
|
|
it("generates on first boot, then reloads the same key across restarts", async () => {
|
|
const vault = new Vault();
|
|
const first = await OidcKeyring.open(new FileKeyStore(keyDir), vault);
|
|
const token = first.sign({ hello: "world" });
|
|
|
|
const restarted = await OidcKeyring.open(new FileKeyStore(keyDir), new Vault());
|
|
expect(restarted.signingKid).toBe(first.signingKid);
|
|
expect(restarted.jwks()).toEqual(first.jwks());
|
|
expect(restarted.verify(token)?.payload).toEqual({ hello: "world" });
|
|
});
|
|
|
|
it("stores the keyset sealed (no private JWK on disk) with owner-only permissions", async () => {
|
|
await OidcKeyring.open(new FileKeyStore(keyDir), new Vault());
|
|
const file = join(keyDir, "keyset.sealed.json");
|
|
const raw = await readFile(file, "utf8");
|
|
expect(raw).not.toContain("privateJwk");
|
|
expect(raw).not.toContain("\"d\"");
|
|
expect((await stat(file)).mode & 0o777).toBe(0o600);
|
|
});
|
|
|
|
it("converges on one key when two processes boot at once", async () => {
|
|
const [a, b] = await Promise.all([
|
|
OidcKeyring.open(new FileKeyStore(keyDir), new Vault()),
|
|
OidcKeyring.open(new FileKeyStore(keyDir), new Vault()),
|
|
]);
|
|
expect(a.signingKid).toBe(b.signingKid);
|
|
});
|
|
|
|
it("refuses to silently regenerate when the keyset cannot be unsealed", async () => {
|
|
await OidcKeyring.open(new FileKeyStore(keyDir), new Vault());
|
|
await expect(OidcKeyring.open(new FileKeyStore(keyDir), new OtherKekSealer())).rejects.toBeInstanceOf(OidcKeyError);
|
|
});
|
|
|
|
it("rejects tokens with a tampered payload or a foreign key", async () => {
|
|
const ring = await OidcKeyring.open(new FileKeyStore(keyDir), new Vault());
|
|
const [header, , signature] = ring.sign({ sub: "a" }).split(".");
|
|
expect(ring.verify(`${header}.${Buffer.from(JSON.stringify({ sub: "b" })).toString("base64url")}.${signature}`)).toBeNull();
|
|
const otherDir = await mkdtemp(join(tmpdir(), "hub-oidc-other-"));
|
|
try {
|
|
const other = await OidcKeyring.open(new FileKeyStore(otherDir), new Vault());
|
|
expect(ring.verify(other.sign({ sub: "a" }))).toBeNull();
|
|
} finally {
|
|
await rm(otherDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("OidcService", () => {
|
|
it("publishes discovery for the exact issuer", () => {
|
|
const doc = harness().service.discovery();
|
|
expect(doc.issuer).toBe("https://api.occupymedia.cc/oidc");
|
|
expect(doc.authorization_endpoint).toBe("https://api.occupymedia.cc/oidc/authorize");
|
|
expect(doc.token_endpoint).toBe("https://api.occupymedia.cc/oidc/token");
|
|
expect(doc.userinfo_endpoint).toBe("https://api.occupymedia.cc/oidc/userinfo");
|
|
expect(doc.jwks_uri).toBe("https://api.occupymedia.cc/oidc/jwks");
|
|
expect(doc.code_challenge_methods_supported).toEqual(["S256"]);
|
|
expect(doc.id_token_signing_alg_values_supported).toEqual(["ES256"]);
|
|
});
|
|
|
|
it("GET /authorize forwards valid requests to the Hub consent page and bad clients to a 400", () => {
|
|
const { service } = harness();
|
|
const { challenge } = pkce();
|
|
const url = new URL(service.authorizeRedirect({ ...authorizeParams(challenge), junk: "x" }));
|
|
expect(`${url.origin}${url.pathname}`).toBe("https://app.occupymedia.cc/oidc/consent");
|
|
expect(url.searchParams.get("code_challenge")).toBe(challenge);
|
|
expect(url.searchParams.get("state")).toBe("st-123");
|
|
expect(url.searchParams.has("junk")).toBe(false);
|
|
|
|
const noPkce = new URL(service.authorizeRedirect(authorizeParams(challenge, { code_challenge_method: "plain" })));
|
|
expect(`${noPkce.origin}${noPkce.pathname}`).toBe(WEB_REDIRECT_URI);
|
|
expect(noPkce.searchParams.get("error")).toBe("invalid_request");
|
|
expect(() => service.authorizeRedirect(authorizeParams(challenge, { redirect_uri: "https://evil.example/cb" }))).toThrow(BadRequestException);
|
|
});
|
|
|
|
it("auto-consents for client_id=web: the session gets a code straight back, no consent screen", async () => {
|
|
const { service } = harness();
|
|
const { challenge } = pkce();
|
|
await expect(service.consent(WORKSPACE, ADA.id, authorizeParams(challenge))).rejects.toBeInstanceOf(UnauthorizedException); // sid-less bearers cannot mint
|
|
const { redirectTo } = await service.consent(WORKSPACE, ADA.id, authorizeParams(challenge), "test-sid");
|
|
const url = new URL(redirectTo);
|
|
expect(`${url.origin}${url.pathname}`).toBe(WEB_REDIRECT_URI);
|
|
expect(url.searchParams.get("code")).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
|
expect(url.searchParams.get("state")).toBe("st-123");
|
|
expect(url.searchParams.get("iss")).toBe(DEFAULT_ISSUER);
|
|
});
|
|
|
|
it("only issues codes to active members with a usable, unreserved username", async () => {
|
|
const { service, users } = harness();
|
|
const { challenge } = pkce();
|
|
await expect(service.consent(WORKSPACE, "33333333-3333-4333-8333-333333333333", authorizeParams(challenge), "test-sid")).rejects.toBeInstanceOf(ForbiddenException);
|
|
await expect(service.consent("44444444-4444-4444-8444-444444444444", ADA.id, authorizeParams(challenge), "test-sid")).rejects.toBeInstanceOf(UnauthorizedException); // cross-workspace sid is not live
|
|
users.members.set(ADA.id, { ...ADA, username: "admin" });
|
|
await expect(service.consent(WORKSPACE, ADA.id, authorizeParams(challenge), "test-sid")).rejects.toBeInstanceOf(ForbiddenException);
|
|
users.members.set(ADA.id, { ...ADA, username: "Ada" });
|
|
await expect(service.consent(WORKSPACE, ADA.id, authorizeParams(challenge), "test-sid")).rejects.toBeInstanceOf(ForbiddenException);
|
|
});
|
|
|
|
it("re-validates relayed params: a forged redirect_uri from the consent page is refused", async () => {
|
|
const { service } = harness();
|
|
const { challenge } = pkce();
|
|
await expect(service.consent(WORKSPACE, ADA.id, authorizeParams(challenge, { redirect_uri: "https://evil.example/cb" }), "test-sid")).rejects.toBeInstanceOf(BadRequestException);
|
|
const { redirectTo } = await service.consent(WORKSPACE, ADA.id, authorizeParams(challenge, { code_challenge_method: "plain" }), "test-sid");
|
|
expect(new URL(redirectTo).searchParams.has("code")).toBe(false);
|
|
});
|
|
|
|
it("maps Hub identity into ES256 id_token / access_token claims", async () => {
|
|
const { service, clock } = harness();
|
|
const { verifier, challenge } = pkce();
|
|
const tokens = await exchange(service, await issueCode(service, challenge), verifier);
|
|
expect(tokens.token_type).toBe("Bearer");
|
|
expect(tokens.scope).toBe("openid profile email");
|
|
|
|
const jwks = await service.jwks();
|
|
expect(decode(tokens.id_token.split(".")[0]!)).toMatchObject({ alg: "ES256", typ: "JWT", kid: jwks.keys[0]!.kid });
|
|
const iat = Math.floor(clock.now / 1000);
|
|
const idClaims = verifiedBy(jwks, tokens.id_token);
|
|
expect(idClaims).toMatchObject({
|
|
iss: "https://api.occupymedia.cc/oidc",
|
|
aud: "web",
|
|
sub: ADA.id,
|
|
preferred_username: "ada.l",
|
|
email: "ada@example.org",
|
|
name: "Ada Lovelace",
|
|
nonce: "n-456",
|
|
iat,
|
|
exp: iat + tokens.expires_in,
|
|
});
|
|
expect(idClaims.at_hash).toBe(createHash("sha256").update(tokens.access_token).digest().subarray(0, 16).toString("base64url"));
|
|
|
|
expect(decode(tokens.access_token.split(".")[0]!).typ).toBe("at+jwt");
|
|
expect(verifiedBy(jwks, tokens.access_token)).toMatchObject({ iss: DEFAULT_ISSUER, sub: ADA.id, client_id: "web", preferred_username: "ada.l" });
|
|
|
|
expect(await service.userinfo(`Bearer ${tokens.access_token}`)).toEqual({
|
|
sub: ADA.id,
|
|
preferred_username: "ada.l",
|
|
name: "Ada Lovelace",
|
|
email: "ada@example.org",
|
|
});
|
|
});
|
|
|
|
it("omits name/email when their scopes were not requested", async () => {
|
|
const { service } = harness();
|
|
const { verifier, challenge } = pkce();
|
|
const tokens = await exchange(service, await issueCode(service, challenge, { scope: "openid" }), verifier);
|
|
const claims = verifiedBy(await service.jwks(), tokens.id_token);
|
|
expect(claims.preferred_username).toBe("ada.l");
|
|
expect(claims).not.toHaveProperty("email");
|
|
expect(claims).not.toHaveProperty("name");
|
|
});
|
|
|
|
it("enforces PKCE: a missing or wrong verifier is rejected (and burns the code)", async () => {
|
|
const { service } = harness();
|
|
const { verifier, challenge } = pkce();
|
|
|
|
const missing = await issueCode(service, challenge);
|
|
expect(await oauthError(exchange(service, missing, undefined))).toEqual({ status: 400, error: "invalid_grant" });
|
|
|
|
const wrong = await issueCode(service, challenge);
|
|
expect(await oauthError(exchange(service, wrong, pkce().verifier))).toEqual({ status: 400, error: "invalid_grant" });
|
|
expect(await oauthError(exchange(service, wrong, verifier))).toEqual({ status: 400, error: "invalid_grant" });
|
|
});
|
|
|
|
it("codes are single-use", async () => {
|
|
const { service } = harness();
|
|
const { verifier, challenge } = pkce();
|
|
const code = await issueCode(service, challenge);
|
|
await exchange(service, code, verifier);
|
|
expect(await oauthError(exchange(service, code, verifier))).toEqual({ status: 400, error: "invalid_grant" });
|
|
});
|
|
|
|
it(`codes expire after ${CODE_TTL_SECONDS}s`, async () => {
|
|
const { service, clock } = harness();
|
|
const { verifier, challenge } = pkce();
|
|
const code = await issueCode(service, challenge);
|
|
clock.now += CODE_TTL_SECONDS * 1000;
|
|
expect(await oauthError(exchange(service, code, verifier))).toEqual({ status: 400, error: "invalid_grant" });
|
|
});
|
|
|
|
it("binds the code to its client and redirect_uri", async () => {
|
|
const { service } = harness();
|
|
const { verifier, challenge } = pkce();
|
|
expect(await oauthError(exchange(service, await issueCode(service, challenge), verifier, { client_id: "other" }))).toEqual({ status: 401, error: "invalid_client" });
|
|
expect(await oauthError(exchange(service, await issueCode(service, challenge), verifier, { redirect_uri: "https://files.occupymedia.cc/" }))).toEqual({ status: 400, error: "invalid_grant" });
|
|
expect(await oauthError(service.token({ grant_type: "password", client_id: "web" }))).toEqual({ status: 400, error: "unsupported_grant_type" });
|
|
});
|
|
|
|
it("userinfo refuses id_tokens, expired tokens and garbage", async () => {
|
|
const { service, clock } = harness();
|
|
const { verifier, challenge } = pkce();
|
|
const tokens = await exchange(service, await issueCode(service, challenge), verifier);
|
|
await expect(service.userinfo(undefined)).rejects.toBeInstanceOf(UnauthorizedException);
|
|
await expect(service.userinfo("Bearer not.a.jwt")).rejects.toBeInstanceOf(UnauthorizedException);
|
|
await expect(service.userinfo(`Bearer ${tokens.id_token}`)).rejects.toBeInstanceOf(UnauthorizedException);
|
|
clock.now += (tokens.expires_in + 1) * 1000;
|
|
await expect(service.userinfo(`Bearer ${tokens.access_token}`)).rejects.toBeInstanceOf(UnauthorizedException);
|
|
});
|
|
|
|
it("keeps the same signing key across a service restart, so issued tokens stay valid", async () => {
|
|
const first = harness();
|
|
const { verifier, challenge } = pkce();
|
|
const tokens = await exchange(first.service, await issueCode(first.service, challenge), verifier);
|
|
const restarted = harness();
|
|
expect(await restarted.service.userinfo(`Bearer ${tokens.access_token}`)).toMatchObject({ sub: ADA.id });
|
|
});
|
|
});
|
|
|
|
describe("OidcService refresh tokens (phase 2)", () => {
|
|
async function sessionBoundTokens(h: ReturnType<typeof harness>) {
|
|
const sid = "33333333-3333-4333-8333-333333333333";
|
|
h.sessions.add(`${WORKSPACE}:${sid}`);
|
|
const { verifier, challenge } = pkce();
|
|
const code = await (async () => {
|
|
const { redirectTo } = await h.service.consent(WORKSPACE, ADA.id, authorizeParams(challenge), sid);
|
|
const url = new URL(redirectTo);
|
|
const got = url.searchParams.get("code");
|
|
if (got === null) throw new Error(`no code in redirect: ${url.searchParams.get("error") ?? ""}`);
|
|
return got;
|
|
})();
|
|
return { sid, tokens: await exchange(h.service, code, verifier) };
|
|
}
|
|
|
|
const refresh = (h: ReturnType<typeof harness>, token: string, extra: Record<string, string> = {}) =>
|
|
h.service.token({ grant_type: "refresh_token", client_id: "web", refresh_token: token, ...extra });
|
|
|
|
it("issues a refresh token bound to the consenting session; rotation returns fresh tokens each time", async () => {
|
|
const h = harness();
|
|
const { sid, tokens } = await sessionBoundTokens(h);
|
|
expect(tokens.refresh_token).toBeDefined();
|
|
const first = await refresh(h, tokens.refresh_token!);
|
|
expect(first.refresh_token).toBeDefined();
|
|
expect(first.refresh_token).not.toBe(tokens.refresh_token);
|
|
expect(decode(first.id_token.split(".")[1]!)).toMatchObject({ sub: ADA.id, preferred_username: ADA.username });
|
|
const rows = [...h.store.rows.values()];
|
|
expect(rows).toHaveLength(2);
|
|
expect(rows.every((row) => row.sid === sid)).toBe(true);
|
|
expect(rows.filter((row) => row.consumedAt === null)).toHaveLength(1);
|
|
});
|
|
|
|
it("a consent without a session id (pre-phase2 client) is rejected outright", async () => {
|
|
const { service } = harness();
|
|
const { challenge } = pkce();
|
|
await expect(service.consent(WORKSPACE, ADA.id, authorizeParams(challenge))).rejects.toBeInstanceOf(UnauthorizedException);
|
|
});
|
|
|
|
it("rejects a revoked (logged-out) session's refresh token and revokes the chain", async () => {
|
|
const h = harness();
|
|
const { sid, tokens } = await sessionBoundTokens(h);
|
|
h.sessions.delete(`${WORKSPACE}:${sid}`);
|
|
await expect(refresh(h, tokens.refresh_token!)).rejects.toMatchObject({ response: { error: "invalid_grant" } } as never);
|
|
expect(h.store.rows.size).toBe(0);
|
|
});
|
|
|
|
it("reuse of a consumed token revokes the whole family", async () => {
|
|
const h = harness();
|
|
const { tokens } = await sessionBoundTokens(h);
|
|
const used = tokens.refresh_token!;
|
|
await refresh(h, used);
|
|
await expect(refresh(h, used)).rejects.toMatchObject({ response: { error: "invalid_grant" } } as never);
|
|
expect(h.store.rows.size).toBe(0);
|
|
});
|
|
|
|
it("an inactive member's chain dies at refresh", async () => {
|
|
const h = harness();
|
|
const { tokens } = await sessionBoundTokens(h);
|
|
h.users.members.delete(ADA.id);
|
|
await expect(refresh(h, tokens.refresh_token!)).rejects.toMatchObject({ response: { error: "invalid_grant" } } as never);
|
|
expect(h.store.rows.size).toBe(0);
|
|
});
|
|
|
|
it("discovery advertises the refresh grant; unknown clients and missing tokens fail closed", async () => {
|
|
const h = harness();
|
|
expect(h.service.discovery().grant_types_supported).toEqual(["authorization_code", "refresh_token"]);
|
|
const { tokens } = await sessionBoundTokens(h);
|
|
await expect(h.service.token({ grant_type: "refresh_token", client_id: "nope", refresh_token: tokens.refresh_token }))
|
|
.rejects.toMatchObject({ response: { error: "invalid_client" } } as never);
|
|
await expect(h.service.token({ grant_type: "refresh_token", client_id: "web" }))
|
|
.rejects.toMatchObject({ response: { error: "invalid_request" } } as never);
|
|
});
|
|
});
|