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:
parent
e475402b4d
commit
b24ebc5862
19 changed files with 1636 additions and 101 deletions
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue