fix: logic-audit P0/P1 wave — CORS PUT, chunked cap, presign checksums, logout await, refresh rotation race

From the logic audit (HEAD 04f5f7e):
- P0 CORS: enableCors methods lacked PUT — browsers could not chunk-upload
  at all (cross-origin preflight refused)
- P0 chunked: validateUpload enforced the 2 GiB whole-body cap on chunked
  completes, so every >2 GiB chunked upload failed after full upload
- P1 presignPut: the SDK's flexible-checksum default added an
  x-amz-checksum-crc32 header browsers cannot reproduce; S3Client now uses
  requestChecksumCalculation WHEN_REQUIRED
- P1 refresh rotation race: concurrent refreshes both succeeded (consume
  ignored rowCount) forking the token family; consume now returns whether
  it won and the loser revokes the chain
- P2 logout: awaits the revocation and ignores missing/forged bearers
honestly
This commit is contained in:
glass beadian 2026-10-18 10:18:02 +00:00
parent e475402b4d
commit b24ebc5862
19 changed files with 1636 additions and 101 deletions

View file

@ -37,7 +37,7 @@ export interface RefreshIssueInput {
export interface OidcRefreshStore {
issue(input: RefreshIssueInput): Promise<void>;
lookupByHash(tokenHash: string): Promise<RefreshTokenRecord | null>;
consume(record: RefreshTokenRecord): Promise<void>;
consume(record: RefreshTokenRecord): Promise<boolean>;
/** 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). */
@ -90,10 +90,12 @@ export class PgRefreshStore implements OidcRefreshStore {
return row === undefined ? null : fromRow(row);
}
async consume(record: RefreshTokenRecord): Promise<void> {
await withTenant(record.workspaceId, (client) =>
/** Single-use consumption: true when THIS call won the rotation race. */
async consume(record: RefreshTokenRecord): Promise<boolean> {
const result = 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]),
);
return (result.rowCount ?? 0) === 1;
}
async revokeFamily(workspaceId: string, familyId: string): Promise<number> {
@ -145,8 +147,11 @@ export class MemoryRefreshStore implements OidcRefreshStore {
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 consume(record: RefreshTokenRecord): Promise<boolean> {
const row = this.rows.get(record.id);
if (row === undefined || row.consumedAt !== null) return false;
row.consumedAt = new Date();
return true;
}
async revokeFamily(workspaceId: string, familyId: string): Promise<number> {

View file

@ -382,7 +382,12 @@ export class OidcService implements OnModuleInit {
throw new OAuthError("invalid_grant", "membership is no longer active");
}
await this.deps.refreshStore.consume(record);
if (!(await this.deps.refreshStore.consume(record))) {
// A concurrent refresh won the rotation: this presentation is already used,
// which is exactly the reuse-detection case — revoke the family.
await this.deps.refreshStore.revokeFamily(record.workspaceId, record.familyId);
throw new OAuthError("invalid_grant", "refresh token reuse detected; the chain has been revoked", HttpStatus.UNAUTHORIZED);
}
const scopes = record.scope.split(" ").filter((s) => s !== "");
const keyring = await this.keyring();
const iat = Math.floor(this.deps.now() / 1000);