Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/oidc-atomic-consume.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'seamless-auth-api': minor
---

`POST /oidc/payloads/:model/:id/consume` now answers `200 { consumed }` instead of `204` (`@seamless-auth/types` 0.34.0-next.4). It sets `consumedAt` in one conditional statement, and `consumed` is true only for the call that set it. Two concurrent refreshes with the same refresh token used to both pass the `consumedAt ??=` check and both rotate. Now exactly one wins, and the provider service treats the other as reuse. An expired record is never consumed.
15 changes: 14 additions & 1 deletion openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -12109,7 +12109,7 @@
},
"/oidc/payloads/{model}/{id}/consume": {
"post": {
"summary": "Mark a provider record consumed",
"summary": "Mark a provider record consumed, and say whether this call did",
"tags": ["OpenID Provider service"],
"security": [{ "serviceToken": [] }],
"parameters": [
Expand Down Expand Up @@ -12141,6 +12141,19 @@
}
],
"responses": {
"200": {
"description": "HTTP 200",
"content": {
"application/json": {
"example": { "consumed": true },
"schema": {
"type": "object",
"properties": { "consumed": { "type": "boolean" } },
"required": ["consumed"]
}
}
}
},
"400": {
"description": "HTTP 400",
"content": {
Expand Down
4 changes: 2 additions & 2 deletions src/controllers/oidcEdge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,8 @@ export async function getOidcPayloadByUid(req: Req<OidcPayloadUidParams>, res: R
}

export async function postOidcPayloadConsume(req: Req<OidcPayloadParams>, res: Response) {
await consumeOidcPayload(req.params.model, req.params.id);
return res.status(204).end();
const consumed = await consumeOidcPayload(req.params.model, req.params.id);
return res.status(200).json({ consumed });
}

export async function deleteOidcPayload(req: Req<OidcPayloadParams>, res: Response) {
Expand Down
18 changes: 17 additions & 1 deletion src/generated/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11938,7 +11938,7 @@ export interface paths {
};
get?: never;
put?: never;
/** Mark a provider record consumed */
/** Mark a provider record consumed, and say whether this call did */
post: {
parameters: {
query?: never;
Expand All @@ -11961,6 +11961,22 @@ export interface paths {
};
requestBody?: never;
responses: {
/** @description HTTP 200 */
200: {
headers: {
[name: string]: unknown;
};
content: {
/**
* @example {
* "consumed": true
* }
*/
'application/json': {
consumed: boolean;
};
};
};
/** @description HTTP 400 */
400: {
headers: {
Expand Down
8 changes: 6 additions & 2 deletions src/routes/oidc.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
OidcEdgeConfigResponseSchema,
OidcEdgeEventSchema,
OidcJwksResponseSchema,
OidcPayloadConsumeResponseSchema,
OidcPayloadGrantParamsSchema,
OidcPayloadParamsSchema,
OidcPayloadRecordSchema,
Expand Down Expand Up @@ -142,8 +143,11 @@ oidcRouter.post(
'/payloads/:model/:id/consume',
{
...edge,
summary: 'Mark a provider record consumed',
schemas: { params: OidcPayloadParamsSchema, response: guarded },
summary: 'Mark a provider record consumed, and say whether this call did',
schemas: {
params: OidcPayloadParamsSchema,
response: { 200: OidcPayloadConsumeResponseSchema, ...guarded },
},
},
postOidcPayloadConsume,
);
Expand Down
17 changes: 15 additions & 2 deletions src/services/oidcPayloadStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,11 +204,24 @@ export function findOidcPayloadByUid(model: OidcPayloadModel, uid: string) {
return findOne({ model, uid });
}

/**
* True only for the caller whose update set `consumedAt`. The condition is in the one
* statement, so of two refreshes racing with the same token exactly one wins, and the edge
* treats the other as reuse rather than issuing a second rotation.
*/
export async function consumeOidcPayload(model: OidcPayloadModel, id: string) {
await OidcPayload.update(
const [affected] = await OidcPayload.update(
{ consumedAt: new Date() },
{ where: { model, id, consumedAt: { [Op.is]: null } } as WhereOptions<OidcPayloadAttributes> },
{
where: {
model,
id,
consumedAt: { [Op.is]: null },
expiresAt: { [Op.gt]: new Date() },
} as WhereOptions<OidcPayloadAttributes>,
},
);
return affected > 0;
}

export async function destroyOidcPayload(model: OidcPayloadModel, id: string) {
Expand Down
8 changes: 7 additions & 1 deletion tests/integration/oidc/oidcEdge.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,13 @@ describe('/oidc/payloads', () => {
});

it('consumes, destroys and revokes by grant', async () => {
expect((await edge('post', '/oidc/payloads/AuthorizationCode/c1/consume')).status).toBe(204);
vi.mocked(consumeOidcPayload).mockResolvedValueOnce(true).mockResolvedValueOnce(false);
const won = await edge('post', '/oidc/payloads/AuthorizationCode/c1/consume');
expect(won.status).toBe(200);
expect(won.body).toEqual({ consumed: true });
expect((await edge('post', '/oidc/payloads/AuthorizationCode/c1/consume')).body).toEqual({
consumed: false,
});
expect((await edge('delete', '/oidc/payloads/Session/s1')).status).toBe(204);
expect((await edge('delete', '/oidc/payloads/RefreshToken/by-grant/g1')).status).toBe(204);

Expand Down
11 changes: 11 additions & 0 deletions tests/postgres/oidcProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,17 @@ describe('oidc_payloads', () => {
expect(await findOidcPayload('AuthorizationCode', id)).toBeNull();
});

it('lets exactly one of many concurrent consumes win', async () => {
const id = randomUUID();
await upsertOidcPayload('RefreshToken', id, { payload: { grantId: id }, expiresIn: 600 });

const results = await Promise.all(
Array.from({ length: 8 }, () => consumeOidcPayload('RefreshToken', id)),
);

expect(results.filter(Boolean)).toHaveLength(1);
});

it('overwrites a record on upsert without losing its consumed state', async () => {
const id = randomUUID();

Expand Down
18 changes: 15 additions & 3 deletions tests/unit/services/oidcPayloadStore.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,12 +267,24 @@ describe('findGrantBoundSession', () => {
});

describe('changing records', () => {
it('consumes once', async () => {
await consumeOidcPayload('AuthorizationCode', 'c1');
it('consumes once, in one conditional statement, and says whether this call did', async () => {
vi.mocked(OidcPayload.update).mockResolvedValueOnce([1] as never);
expect(await consumeOidcPayload('RefreshToken', 'r1')).toBe(true);
expect(OidcPayload.update).toHaveBeenCalledWith(
{ consumedAt: expect.any(Date) },
{ where: { model: 'AuthorizationCode', id: 'c1', consumedAt: { [Op.is]: null } } },
{
where: {
model: 'RefreshToken',
id: 'r1',
consumedAt: { [Op.is]: null },
expiresAt: { [Op.gt]: expect.any(Date) },
},
},
);

// The second of two racing refreshes: the row already had consumedAt.
vi.mocked(OidcPayload.update).mockResolvedValueOnce([0] as never);
expect(await consumeOidcPayload('RefreshToken', 'r1')).toBe(false);
});

it('destroys one record, or every record of a grant', async () => {
Expand Down
Loading