feat: OIDC phase 2 — session-bound refresh tokens, server-side logout
Hub sessions gain an optional sid backed by auth_sessions (login/reissue write the row; POST /auth/logout revokes it; pre-phase2 tokens are refused at reissue so revocation applies from the next epoch bump). The OIDC provider issues rotating refresh tokens (oidc_refresh_tokens, SHA-256 at rest, family-wide revocation on reuse detection) that live and die with the consenting Hub session — an oCIS session can no longer outlive the sign-in that minted it, and Hub logout propagates at the next refresh instead of the next silent-authorize. Discovery advertises refresh_token. Migration 034; RLS via the audit_append definer pattern for the unauthenticated token-endpoint lookup.
This commit is contained in:
parent
d6eb39ff27
commit
643fbb94bc
10 changed files with 601 additions and 19 deletions
|
|
@ -48,7 +48,7 @@ export class OidcController {
|
|||
@Header("Cache-Control", "no-store")
|
||||
consent(@Req() request: Request & AuthenticatedRequest, @Body() body: unknown): Promise<{ redirectTo: string }> {
|
||||
const session = requireSession(request);
|
||||
return this.oidc.consent(session.workspaceId, session.userId, body);
|
||||
return this.oidc.consent(session.workspaceId, session.userId, body, session.sid ?? null);
|
||||
}
|
||||
|
||||
@Post("token")
|
||||
|
|
|
|||
171
apps/api/src/oidc/oidc.refresh-store.ts
Normal file
171
apps/api/src/oidc/oidc.refresh-store.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import { withSystem, withTenant } from "../db/client.js";
|
||||
|
||||
/**
|
||||
* Storage for OIDC refresh tokens (phase 2). One rotation chain shares
|
||||
* family_id; presenting a consumed token revokes the family. Tokens bind to
|
||||
* an auth_sessions row (sid) so their effective lifetime is the Hub
|
||||
* session's: logout (or expiry) invalidates every downstream token.
|
||||
*
|
||||
* PgRefreshStore is the production impl. The lookup runs on the
|
||||
* unauthenticated token endpoint before any tenant context exists, so it
|
||||
* goes through the definer-rights oidc_refresh_lookup() (the audit_append
|
||||
* pattern); every other call runs inside withTenant.
|
||||
*/
|
||||
export interface RefreshTokenRecord {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
userId: string;
|
||||
sid: string;
|
||||
clientId: string;
|
||||
scope: string;
|
||||
familyId: string;
|
||||
tokenHash: string;
|
||||
createdAt: Date;
|
||||
consumedAt: Date | null;
|
||||
}
|
||||
|
||||
export interface RefreshIssueInput {
|
||||
workspaceId: string;
|
||||
userId: string;
|
||||
sid: string;
|
||||
clientId: string;
|
||||
scope: string;
|
||||
familyId: string;
|
||||
tokenHash: string;
|
||||
}
|
||||
|
||||
export interface OidcRefreshStore {
|
||||
issue(input: RefreshIssueInput): Promise<void>;
|
||||
lookupByHash(tokenHash: string): Promise<RefreshTokenRecord | null>;
|
||||
consume(record: RefreshTokenRecord): Promise<void>;
|
||||
/** Reuse detection: delete the whole chain; returns rows removed. */
|
||||
revokeFamily(workspaceId: string, familyId: string): Promise<number>;
|
||||
/** The minting Hub session must still be live (not revoked, not expired). */
|
||||
sessionAlive(workspaceId: string, sid: string): Promise<boolean>;
|
||||
/** Drop rows whose session is gone (best-effort hygiene, same tenant). */
|
||||
sweepOrphans(workspaceId: string): Promise<void>;
|
||||
}
|
||||
|
||||
interface Row {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
user_id: string;
|
||||
sid: string;
|
||||
client_id: string;
|
||||
scope: string;
|
||||
family_id: string;
|
||||
token_hash: string;
|
||||
created_at: Date;
|
||||
consumed_at: Date | null;
|
||||
}
|
||||
|
||||
function fromRow(row: Row): RefreshTokenRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
workspaceId: row.workspace_id,
|
||||
userId: row.user_id,
|
||||
sid: row.sid,
|
||||
clientId: row.client_id,
|
||||
scope: row.scope,
|
||||
familyId: row.family_id,
|
||||
tokenHash: row.token_hash,
|
||||
createdAt: new Date(row.created_at),
|
||||
consumedAt: row.consumed_at === null ? null : new Date(row.consumed_at),
|
||||
};
|
||||
}
|
||||
|
||||
export class PgRefreshStore implements OidcRefreshStore {
|
||||
async issue(input: RefreshIssueInput): Promise<void> {
|
||||
await withTenant(input.workspaceId, (client) =>
|
||||
client.query(
|
||||
`INSERT INTO oidc_refresh_tokens (workspace_id, user_id, sid, client_id, scope, family_id, token_hash)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[input.workspaceId, input.userId, input.sid, input.clientId, input.scope, input.familyId, input.tokenHash],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async lookupByHash(tokenHash: string): Promise<RefreshTokenRecord | null> {
|
||||
const row = (await withSystem((client) => client.query<Row>("SELECT * FROM oidc_refresh_lookup($1)", [tokenHash]))).rows[0];
|
||||
return row === undefined ? null : fromRow(row);
|
||||
}
|
||||
|
||||
async consume(record: RefreshTokenRecord): Promise<void> {
|
||||
await withTenant(record.workspaceId, (client) =>
|
||||
client.query("UPDATE oidc_refresh_tokens SET consumed_at = now() WHERE id = $1 AND consumed_at IS NULL", [record.id]),
|
||||
);
|
||||
}
|
||||
|
||||
async revokeFamily(workspaceId: string, familyId: string): Promise<number> {
|
||||
return (await withTenant(workspaceId, (client) =>
|
||||
client.query("DELETE FROM oidc_refresh_tokens WHERE workspace_id = $1 AND family_id = $2", [workspaceId, familyId]),
|
||||
)).rowCount ?? 0;
|
||||
}
|
||||
|
||||
async sessionAlive(workspaceId: string, sid: string): Promise<boolean> {
|
||||
const found = (await withTenant(workspaceId, (client) =>
|
||||
client.query(
|
||||
"SELECT 1 FROM auth_sessions WHERE id = $1 AND workspace_id = $2 AND revoked_at IS NULL AND expires_at > now()",
|
||||
[sid, workspaceId],
|
||||
),
|
||||
)).rows.length;
|
||||
return found === 1;
|
||||
}
|
||||
|
||||
async sweepOrphans(workspaceId: string): Promise<void> {
|
||||
await withTenant(workspaceId, (client) =>
|
||||
client.query(
|
||||
`DELETE FROM oidc_refresh_tokens t USING auth_sessions s
|
||||
WHERE t.sid = s.id AND (s.revoked_at IS NOT NULL OR s.expires_at < now())`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** In-memory impl for unit tests (same semantics, no Postgres). */
|
||||
export class MemoryRefreshStore implements OidcRefreshStore {
|
||||
readonly rows = new Map<string, RefreshTokenRecord>();
|
||||
|
||||
async issue(input: RefreshIssueInput): Promise<void> {
|
||||
this.rows.set(input.tokenHash, {
|
||||
id: input.tokenHash,
|
||||
workspaceId: input.workspaceId,
|
||||
userId: input.userId,
|
||||
sid: input.sid,
|
||||
clientId: input.clientId,
|
||||
scope: input.scope,
|
||||
familyId: input.familyId,
|
||||
tokenHash: input.tokenHash,
|
||||
createdAt: new Date(),
|
||||
consumedAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
async lookupByHash(tokenHash: string): Promise<RefreshTokenRecord | null> {
|
||||
return this.rows.get(tokenHash) ?? null;
|
||||
}
|
||||
|
||||
async consume(record: RefreshTokenRecord): Promise<void> {
|
||||
for (const row of this.rows.values()) if (row.id === record.id) row.consumedAt = new Date();
|
||||
}
|
||||
|
||||
async revokeFamily(workspaceId: string, familyId: string): Promise<number> {
|
||||
let removed = 0;
|
||||
for (const [hash, row] of [...this.rows]) {
|
||||
if (row.workspaceId === workspaceId && row.familyId === familyId) {
|
||||
this.rows.delete(hash);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
async sessionAlive(workspaceId: string, sid: string): Promise<boolean> {
|
||||
return this.liveSessions.has(`${workspaceId}:${sid}`);
|
||||
}
|
||||
|
||||
async sweepOrphans(): Promise<void> {}
|
||||
|
||||
/** Test control: which (workspace, sid) pairs count as live sessions. */
|
||||
readonly liveSessions = new Set<string>();
|
||||
}
|
||||
|
|
@ -30,6 +30,9 @@ import {
|
|||
type OidcConfig,
|
||||
} from "./oidc.config.js";
|
||||
import { FileKeyStore, OidcKeyring, type PublicJwk } from "./oidc.keys.js";
|
||||
import { PgRefreshStore, type OidcRefreshStore } from "./oidc.refresh-store.js";
|
||||
|
||||
const REFRESH_GRANT = "refresh_token";
|
||||
|
||||
/** The Hub account an authorization code is issued for. */
|
||||
export interface OidcUser {
|
||||
|
|
@ -52,6 +55,8 @@ export interface OidcDependencies {
|
|||
now: () => number;
|
||||
/** Usernames that already exist inside oCIS and must never be claimed by a Hub user. */
|
||||
reservedUsernames: () => ReadonlySet<string>;
|
||||
/** Refresh-token persistence (phase 2); the default hits Postgres. */
|
||||
refreshStore: OidcRefreshStore;
|
||||
}
|
||||
|
||||
export const OIDC_DEPENDENCIES: unique symbol = Symbol("OIDC_DEPENDENCIES");
|
||||
|
|
@ -62,6 +67,8 @@ export interface TokenResponse {
|
|||
expires_in: number;
|
||||
id_token: string;
|
||||
scope: string;
|
||||
/** Present on the first-party client: rotate on every use (phase 2). */
|
||||
refresh_token?: string;
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
|
|
@ -85,6 +92,9 @@ interface CodeRecord {
|
|||
scopes: string[];
|
||||
nonce: string | null;
|
||||
user: OidcUser;
|
||||
/** The consenting Hub session — minted refresh tokens die with it. */
|
||||
workspaceId: string;
|
||||
sid: string | null;
|
||||
authTime: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
|
@ -166,6 +176,7 @@ export class OidcService implements OnModuleInit {
|
|||
users: deps?.users ?? new DatabaseUserDirectory(),
|
||||
now: deps?.now ?? (() => Date.now()),
|
||||
reservedUsernames: deps?.reservedUsernames ?? reservedFromEnv,
|
||||
refreshStore: deps?.refreshStore ?? new PgRefreshStore(),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -188,7 +199,7 @@ export class OidcService implements OnModuleInit {
|
|||
jwks_uri: `${issuer}/jwks`,
|
||||
response_types_supported: ["code"],
|
||||
response_modes_supported: ["query"],
|
||||
grant_types_supported: ["authorization_code"],
|
||||
grant_types_supported: ["authorization_code", REFRESH_GRANT],
|
||||
subject_types_supported: ["public"],
|
||||
id_token_signing_alg_values_supported: ["ES256"],
|
||||
token_endpoint_auth_methods_supported: ["none"],
|
||||
|
|
@ -222,7 +233,7 @@ export class OidcService implements OnModuleInit {
|
|||
}
|
||||
|
||||
/** POST /oidc/authorize/consent — the session's user approves (auto, first-party) and gets a code. */
|
||||
async consent(workspaceId: string, userId: string, body: unknown): Promise<{ redirectTo: string }> {
|
||||
async consent(workspaceId: string, userId: string, body: unknown, sid: string | null = null): Promise<{ redirectTo: string }> {
|
||||
const config = this.config();
|
||||
const result = validateAuthorize(authorizeParamsFrom(body));
|
||||
if (!result.ok && !result.redirect) throw new BadRequestException(`${result.error}: ${result.description}`);
|
||||
|
|
@ -253,6 +264,8 @@ export class OidcService implements OnModuleInit {
|
|||
scopes: request.scopes,
|
||||
nonce: request.nonce,
|
||||
user,
|
||||
workspaceId,
|
||||
sid,
|
||||
authTime: Math.floor(now / 1000),
|
||||
expiresAt: now + CODE_TTL_SECONDS * 1000,
|
||||
});
|
||||
|
|
@ -263,11 +276,13 @@ export class OidcService implements OnModuleInit {
|
|||
return { redirectTo: redirect.toString() };
|
||||
}
|
||||
|
||||
/** POST /oidc/token — authorization_code + PKCE verifier → id_token + access_token. */
|
||||
/** POST /oidc/token — authorization_code+PKCE, or refresh_token rotation. */
|
||||
async token(body: unknown): Promise<TokenResponse> {
|
||||
const config = this.config();
|
||||
const params = typeof body === "object" && body !== null && !Array.isArray(body) ? (body as Record<string, unknown>) : {};
|
||||
if (str(params, "grant_type") !== "authorization_code") throw new OAuthError("unsupported_grant_type", "only authorization_code is supported");
|
||||
const grantType = str(params, "grant_type");
|
||||
if (grantType === REFRESH_GRANT) return this.refreshGrant(params);
|
||||
if (grantType !== "authorization_code") throw new OAuthError("unsupported_grant_type", "only authorization_code and refresh_token are supported");
|
||||
const config = this.config();
|
||||
const clientId = str(params, "client_id");
|
||||
if (clientId !== WEB_CLIENT_ID) throw new OAuthError("invalid_client", "unknown client_id", HttpStatus.UNAUTHORIZED);
|
||||
const code = str(params, "code");
|
||||
|
|
@ -311,7 +326,90 @@ export class OidcService implements OnModuleInit {
|
|||
at_hash: atHash(accessToken),
|
||||
...claims,
|
||||
});
|
||||
return { access_token: accessToken, token_type: "Bearer", expires_in: TOKEN_TTL_SECONDS, id_token: idToken, scope };
|
||||
const response: TokenResponse = { access_token: accessToken, token_type: "Bearer", expires_in: TOKEN_TTL_SECONDS, id_token: idToken, scope };
|
||||
if (record.sid !== null) {
|
||||
response.refresh_token = await this.mintRefreshToken({ ...record, sid: record.sid });
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 2: a rotation chain lives and dies with the consenting Hub
|
||||
* session. A code minted by a pre-phase2 session token (no sid) gets no
|
||||
* refresh token — an unbindable token must not be refreshable.
|
||||
*/
|
||||
private async mintRefreshToken(record: CodeRecord & { sid: string }): Promise<string> {
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
await this.deps.refreshStore.sweepOrphans(record.workspaceId);
|
||||
await this.deps.refreshStore.issue({
|
||||
workspaceId: record.workspaceId,
|
||||
userId: record.user.id,
|
||||
sid: record.sid,
|
||||
clientId: record.clientId,
|
||||
scope: record.scopes.join(" "),
|
||||
familyId: randomUUID(),
|
||||
tokenHash: sha256(token).toString("base64url"),
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* grant_type=refresh_token: rotate-on-use with reuse detection. Presenting
|
||||
* a consumed token revokes its whole chain (OAuth BCP) — that is the
|
||||
* signal that a token escaped. Every accept re-checks the minting Hub
|
||||
* session (logout/expiry propagates here) and active membership.
|
||||
*/
|
||||
private async refreshGrant(params: Record<string, unknown>): Promise<TokenResponse> {
|
||||
const config = this.config();
|
||||
const clientId = str(params, "client_id");
|
||||
if (clientId !== WEB_CLIENT_ID) throw new OAuthError("invalid_client", "unknown client_id", HttpStatus.UNAUTHORIZED);
|
||||
const presented = str(params, "refresh_token");
|
||||
if (presented === undefined || presented === "") throw new OAuthError("invalid_request", "refresh_token is required");
|
||||
|
||||
const record = await this.deps.refreshStore.lookupByHash(sha256(presented).toString("base64url"));
|
||||
if (record === null || record.clientId !== clientId) throw new OAuthError("invalid_grant", "refresh token is invalid");
|
||||
if (record.consumedAt !== null) {
|
||||
await this.deps.refreshStore.revokeFamily(record.workspaceId, record.familyId);
|
||||
throw new OAuthError("invalid_grant", "refresh token reuse detected; the chain has been revoked");
|
||||
}
|
||||
if (!(await this.deps.refreshStore.sessionAlive(record.workspaceId, record.sid))) {
|
||||
await this.deps.refreshStore.revokeFamily(record.workspaceId, record.familyId);
|
||||
throw new OAuthError("invalid_grant", "the signing-in session has ended; sign in again");
|
||||
}
|
||||
const user = await this.deps.users.activeMember(record.workspaceId, record.userId);
|
||||
if (user === null) {
|
||||
await this.deps.refreshStore.revokeFamily(record.workspaceId, record.familyId);
|
||||
throw new OAuthError("invalid_grant", "membership is no longer active");
|
||||
}
|
||||
|
||||
await this.deps.refreshStore.consume(record);
|
||||
const scopes = record.scope.split(" ").filter((s) => s !== "");
|
||||
const keyring = await this.keyring();
|
||||
const iat = Math.floor(this.deps.now() / 1000);
|
||||
const exp = iat + TOKEN_TTL_SECONDS;
|
||||
const scope = record.scope;
|
||||
const claims = this.userClaims(user, scopes);
|
||||
const accessToken = keyring.sign(
|
||||
{ iss: config.issuer, aud: clientId, sub: user.id, client_id: clientId, scope, iat, exp, jti: randomUUID(), ...claims },
|
||||
"at+jwt",
|
||||
);
|
||||
const idToken = keyring.sign({
|
||||
iss: config.issuer, aud: clientId, sub: user.id, iat, exp, at_hash: atHash(accessToken), ...claims,
|
||||
});
|
||||
const rotated = randomBytes(32).toString("base64url");
|
||||
await this.deps.refreshStore.issue({
|
||||
workspaceId: record.workspaceId,
|
||||
userId: record.userId,
|
||||
sid: record.sid,
|
||||
clientId: record.clientId,
|
||||
scope: record.scope,
|
||||
familyId: record.familyId,
|
||||
tokenHash: sha256(rotated).toString("base64url"),
|
||||
});
|
||||
return {
|
||||
access_token: accessToken, token_type: "Bearer", expires_in: TOKEN_TTL_SECONDS,
|
||||
id_token: idToken, scope, refresh_token: rotated,
|
||||
};
|
||||
}
|
||||
|
||||
/** GET /oidc/userinfo — claims for a valid access token (stateless: read from the token itself). */
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ 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" };
|
||||
|
|
@ -71,19 +72,22 @@ afterEach(async () => {
|
|||
await rm(keyDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function harness(): { service: OidcService; users: FakeUsers; clock: { now: number }; config: OidcConfig } {
|
||||
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;
|
||||
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 };
|
||||
return { service, users, clock, config, store, sessions };
|
||||
}
|
||||
|
||||
async function issueCode(service: OidcService, challenge: string, extra: Record<string, string> = {}): Promise<string> {
|
||||
|
|
@ -351,3 +355,78 @@ describe("OidcService", () => {
|
|||
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 code from a pre-phase2 session (no sid) mints no refresh token", async () => {
|
||||
const h = harness();
|
||||
const { verifier, challenge } = pkce();
|
||||
const tokens = await exchange(h.service, await issueCode(h.service, challenge), verifier);
|
||||
expect(tokens.refresh_token).toBeUndefined();
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue