@udibo/oauth2 0.34.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -139,7 +139,10 @@ export interface AuthRequestStorageFactory {
139
139
  *
140
140
  * It has no `take`: the record travels with the browser and cannot be claimed
141
141
  * atomically, so a replayed callback is bounded by the authorization server's
142
- * single-use code and PKCE.
142
+ * single-use code and PKCE. Two concurrent callbacks for one sign-in both reach
143
+ * the token endpoint, and an authorization server that revokes on code replay,
144
+ * as `@udibo/oauth2`'s does, revokes the tokens the winner received, so the
145
+ * user is signed out and has to sign in again.
143
146
  *
144
147
  * @example
145
148
  * ```ts
@@ -63,7 +63,10 @@ function cookieAttributes(options, maxAgeSeconds) {
63
63
  *
64
64
  * It has no `take`: the record travels with the browser and cannot be claimed
65
65
  * atomically, so a replayed callback is bounded by the authorization server's
66
- * single-use code and PKCE.
66
+ * single-use code and PKCE. Two concurrent callbacks for one sign-in both reach
67
+ * the token endpoint, and an authorization server that revokes on code replay,
68
+ * as `@udibo/oauth2`'s does, revokes the tokens the winner received, so the
69
+ * user is signed out and has to sign in again.
67
70
  *
68
71
  * @example
69
72
  * ```ts
@@ -55,12 +55,13 @@ function newSession(userId, signedInFor) {
55
55
  async function createState(config, signingKey, issuer) {
56
56
  const userService = new MemoryUserService();
57
57
  const clientService = new MemoryClientService(userService);
58
+ const authorizationCodeService = new MemoryAuthorizationCodeService({ clientService, userService });
58
59
  const tokenService = new MemoryTokenService({
59
60
  clientService,
60
61
  userService,
61
62
  accessTokenLifetime: config.accessTokenLifetime,
63
+ authorizationCodeService,
62
64
  });
63
- const authorizationCodeService = new MemoryAuthorizationCodeService({ clientService, userService });
64
65
  const grants = {};
65
66
  if (config.grants.authorization_code) {
66
67
  grants.authorization_code = new AuthorizationCodeGrant({
@@ -314,6 +314,35 @@ export interface AuthorizationServerOptions<Client extends ClientInterface, User
314
314
  * contains a space or non-ASCII character.
315
315
  */
316
316
  acrValuesSupported?: readonly string[];
317
+ /**
318
+ * Called when the revocation endpoint revokes the rotation family of a
319
+ * refresh token that rotation already replaced — the client presented a
320
+ * token that is no longer live, so record it alongside your reuse events.
321
+ * It fires once per such request, after `revokeFamily`, only for a token
322
+ * issued to the authenticated client. It never fires `onTokenReuse`: a
323
+ * sign-out racing the client's own refresh presents the same token, so this
324
+ * is not by itself evidence of theft.
325
+ *
326
+ * Trigger point `rotated-out-revocation` (see `docs/trigger-points.md`).
327
+ * Fire-and-forget: it is awaited, but a throw is caught and logged, never
328
+ * rethrown, so a failing hook cannot change the `200` the endpoint answers.
329
+ */
330
+ onRotatedOutRevocation?: (event: RotatedOutRevocationEvent<Client, User>) => void | Promise<void>;
331
+ }
332
+ /**
333
+ * What the revocation endpoint observed when it revoked the rotation family of
334
+ * a refresh token that rotation already replaced. Passed to
335
+ * {@link AuthorizationServerOptions.onRotatedOutRevocation}.
336
+ */
337
+ export interface RotatedOutRevocationEvent<Client extends ClientInterface, User> {
338
+ /** The rotation family the presented token belonged to. */
339
+ familyId: string;
340
+ /** The client the presented token was issued to, which authenticated. */
341
+ client: Client;
342
+ /** The user the presented token was issued for, if any. */
343
+ user?: User;
344
+ /** Whether {@linkcode TokenServiceInterface.revokeFamily} revoked anything. */
345
+ familyRevoked: boolean;
317
346
  }
318
347
  /**
319
348
  * Function to authenticate the user during authorization.
@@ -707,7 +736,8 @@ export declare class AuthorizationServer<Client extends ClientInterface, User, S
707
736
  * revoked other than by rotation, a token with no family, and a service
708
737
  * without `getRevokedRefreshToken` and `revokeFamily` revoke nothing more.
709
738
  * The refresh grant's `replayGraceSeconds` does not apply here, and
710
- * `onTokenReuse` does not fire.
739
+ * `onTokenReuse` does not fire; the server reports the revocation to
740
+ * {@linkcode AuthorizationServerOptions.onRotatedOutRevocation} instead.
711
741
  *
712
742
  * Always returns 200 OK, even if the token was invalid, already revoked, or
713
743
  * issued to another client: RFC 7009 Section 2.2 requires that answer so the
@@ -860,7 +890,7 @@ export { AbstractGrant } from "./grants/grant.ts";
860
890
  export type { DispatchableGrant, GrantOptions, GrantServices, } from "./grants/grant.ts";
861
891
  export { AuthorizationCodeGrant } from "./grants/authorization-code.ts";
862
892
  export type { PKCEClientCredentials } from "./grants/authorization-code.ts";
863
- export type { AuthorizationCodeGrantOptions, AuthorizationCodeGrantServices, GenerateAuthorizationCodeOptions, } from "./grants/authorization-code.ts";
893
+ export type { AuthorizationCodeGrantOptions, AuthorizationCodeGrantServices, CodeReuseEvent, GenerateAuthorizationCodeOptions, } from "./grants/authorization-code.ts";
864
894
  export { ClientCredentialsGrant } from "./grants/client-credentials.ts";
865
895
  export type { ClientCredentialsGrantOptions, ClientCredentialsGrantServices, } from "./grants/client-credentials.ts";
866
896
  export { createJwtAccessTokenGenerator, exportSigningKeyJwk, generateSigningKey, importSigningKeyJwk, RotatingSigningKeyProvider, type RotatingSigningKeyProviderOptions, type SigningKey, type SigningKeyProvider, signJwt, StaticSigningKeyProvider, verifyJwt, type VerifyJwtOptions, } from "./signing-keys.ts";
@@ -200,6 +200,7 @@ export class AuthorizationServer extends ResourceServer {
200
200
  #subjectOf;
201
201
  #userClaims;
202
202
  #introspectionClaims;
203
+ #onRotatedOutRevocation;
203
204
  #canIntrospectToken;
204
205
  #resolve;
205
206
  /**
@@ -254,6 +255,7 @@ export class AuthorizationServer extends ResourceServer {
254
255
  this.#userClaims = options.userClaims;
255
256
  this.#introspectionClaims = options.introspectionClaims;
256
257
  this.#canIntrospectToken = options.canIntrospectToken;
258
+ this.#onRotatedOutRevocation = options.onRotatedOutRevocation;
257
259
  }
258
260
  #handleEndpointError(error, prepare) {
259
261
  const oauth2Error = toOAuth2Error(error);
@@ -1001,7 +1003,8 @@ export class AuthorizationServer extends ResourceServer {
1001
1003
  * revoked other than by rotation, a token with no family, and a service
1002
1004
  * without `getRevokedRefreshToken` and `revokeFamily` revoke nothing more.
1003
1005
  * The refresh grant's `replayGraceSeconds` does not apply here, and
1004
- * `onTokenReuse` does not fire.
1006
+ * `onTokenReuse` does not fire; the server reports the revocation to
1007
+ * {@linkcode AuthorizationServerOptions.onRotatedOutRevocation} instead.
1005
1008
  *
1006
1009
  * Always returns 200 OK, even if the token was invalid, already revoked, or
1007
1010
  * issued to another client: RFC 7009 Section 2.2 requires that answer so the
@@ -1050,7 +1053,23 @@ export class AuthorizationServer extends ResourceServer {
1050
1053
  if (rotatedOut?.rotatedAt &&
1051
1054
  rotatedOut.familyId &&
1052
1055
  rotatedOut.client.id.toString() === client.id.toString()) {
1053
- await tokenService.revokeFamily(rotatedOut.familyId);
1056
+ const familyRevoked = await tokenService.revokeFamily(rotatedOut.familyId);
1057
+ await this.#notifyRotatedOutRevocation({
1058
+ familyId: rotatedOut.familyId,
1059
+ client: rotatedOut.client,
1060
+ user: rotatedOut.user,
1061
+ familyRevoked,
1062
+ });
1063
+ }
1064
+ }
1065
+ async #notifyRotatedOutRevocation(event) {
1066
+ if (!this.#onRotatedOutRevocation)
1067
+ return;
1068
+ try {
1069
+ await this.#onRotatedOutRevocation(event);
1070
+ }
1071
+ catch (error) {
1072
+ console.error("[@udibo/oauth2] onRotatedOutRevocation hook failed:", error instanceof Error ? error.message : error);
1054
1073
  }
1055
1074
  }
1056
1075
  async #resolveToken(tokenService, value, hint) {
@@ -17,6 +17,27 @@ export interface AuthorizationCodeGrantServices<Client extends ClientInterface,
17
17
  /** Generates, stores, and looks up authorization codes for the exchange. */
18
18
  authorizationCodeService: AuthorizationCodeServiceInterface<Client, User, S>;
19
19
  }
20
+ /**
21
+ * What the authorization code grant observed when a code that already issued
22
+ * tokens was presented again. Passed to
23
+ * {@link AuthorizationCodeGrantOptions.onCodeReuse}.
24
+ */
25
+ export interface CodeReuseEvent<Client extends ClientInterface> {
26
+ /**
27
+ * The SHA-256 digest of the replayed code, as lowercase hex — the value a
28
+ * store that hashes codes keys them on, so it correlates without recording
29
+ * the code itself.
30
+ */
31
+ codeHash: string;
32
+ /**
33
+ * The client that presented the code. It is the code's own client only when
34
+ * the token service's `revokeCode` honors its `client` argument. A public
35
+ * client, or any client the grant looks up by id because
36
+ * `requireClientAuthentication` is `false`, is named by `client_id` alone,
37
+ * so whoever holds the spent code can present it as that client.
38
+ */
39
+ client: Client;
40
+ }
20
41
  /**
21
42
  * Options for configuring the authorization code grant.
22
43
  *
@@ -64,6 +85,19 @@ export interface AuthorizationCodeGrantOptions<Client extends ClientInterface, U
64
85
  * @see https://datatracker.ietf.org/doc/html/rfc9700#section-2.1.1
65
86
  */
66
87
  requireClientAuthentication?: boolean;
88
+ /**
89
+ * Called when a code that already issued tokens is presented again and the
90
+ * token service's `revokeCode` revoked those tokens — record it as a
91
+ * security event. A `revokeCode` that honors its `client` argument revokes
92
+ * nothing, and so does not fire this, for a client other than the code's
93
+ * own; {@linkcode CodeReuseEvent.client} says who that excludes.
94
+ *
95
+ * Trigger point `code-reuse` (see `docs/trigger-points.md`).
96
+ * Fire-and-forget: it is awaited, but a throw is caught and logged, never
97
+ * rethrown, so a failing security-logging hook cannot change the
98
+ * client-facing `invalid_grant` a replayed code always yields.
99
+ */
100
+ onCodeReuse?: (event: CodeReuseEvent<Client>) => void | Promise<void>;
67
101
  }
68
102
  /** Client credentials extended with optional PKCE code_verifier. */
69
103
  export interface PKCEClientCredentials extends ClientCredentials {
@@ -103,6 +137,7 @@ export interface GenerateAuthorizationCodeOptions<Client extends ClientInterface
103
137
  * @see https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#section-2.1.1
104
138
  */
105
139
  export declare class AuthorizationCodeGrant<Client extends ClientInterface, User, S extends AbstractScope = BasicScope> extends AbstractGrant<Client, User, S, AuthorizationCodeGrantServices<Client, User, S>> {
140
+ #private;
106
141
  /** The OAuth2 grant type: `authorization_code`. */
107
142
  readonly grantType = "authorization_code";
108
143
  /** The PKCE challenge methods this grant accepts. Defaults to `S256` only. */
@@ -169,11 +204,27 @@ export declare class AuthorizationCodeGrant<Client extends ClientInterface, User
169
204
  * Exchanges an authorization code for an access token. If PKCE was used
170
205
  * during authorization, the code_verifier must be provided.
171
206
  *
172
- * The code is claimed by revoking it before the client-match, verifier, and
173
- * `redirect_uri` checks, so a failed exchange still consumes the code. Only
174
- * the caller whose revoke wins receives a token, given an atomic
175
- * `authorizationCodeService.revoke`. Presenting a code that already minted
176
- * tokens asks `tokenService.revokeCode` to revoke those tokens and fails with
207
+ * A failed exchange still consumes the code: one that fails the
208
+ * client-match, verifier or `redirect_uri` check, whose token cannot be
209
+ * generated, or that `exchangeCode` refuses with an OAuth2 error, is claimed
210
+ * before the error is thrown. Any other error from `exchangeCode`, such as a
211
+ * lost database connection, leaves the code claimable. Only the caller whose
212
+ * claim wins receives a token, given an atomic
213
+ * `authorizationCodeService.revoke`.
214
+ *
215
+ * When the token service implements
216
+ * {@linkcode TokenServiceInterface.exchangeCode}, the grant claims the code
217
+ * and saves the token in that one call. Otherwise it claims the code with
218
+ * `authorizationCodeService.revoke` before its checks and saves the token
219
+ * with `tokenService.save` after them; a replay that lands between those
220
+ * two calls finds no token to revoke, so the token saved afterwards stays
221
+ * live.
222
+ *
223
+ * A code presented after it was claimed, whether it was gone when the
224
+ * request read it or a concurrent exchange won the claim, even one that
225
+ * then fails its checks, asks
226
+ * `tokenService.revokeCode` to revoke the tokens issued from it on behalf of
227
+ * the presenting client, fires `onCodeReuse` when it did, and fails with
177
228
  * `invalid_grant` (RFC 6819 §4.4.1.1).
178
229
  *
179
230
  * @throws {InvalidRequestError} When `code` is missing, a required `code_verifier` is absent, or the verifier is malformed.
@@ -4,9 +4,10 @@
4
4
  * @module
5
5
  */
6
6
  import { snapshotAuthenticationContext } from "../../utils/authentication-context.js";
7
- import { InvalidClientError, InvalidGrantError, InvalidRequestError, ServerError, } from "../../errors.js";
7
+ import { InvalidClientError, InvalidGrantError, InvalidRequestError, isOAuth2Error, ServerError, } from "../../errors.js";
8
8
  import { challengeMethods as defaultChallengeMethods, getChallengeMethod, validateCodeVerifier, } from "../../utils/pkce.js";
9
9
  import { authenticateClientCredentials } from "../client-authentication.js";
10
+ import { sha256Hash } from "../utils/hash.js";
10
11
  import { AbstractGrant, } from "./grant.js";
11
12
  /**
12
13
  * The authorization code grant type.
@@ -37,6 +38,7 @@ export class AuthorizationCodeGrant extends AbstractGrant {
37
38
  * @see https://datatracker.ietf.org/doc/html/rfc7636#section-4.5
38
39
  */
39
40
  requireClientAuthentication;
41
+ #onCodeReuse;
40
42
  /** Creates the grant from its {@linkcode AuthorizationCodeGrantOptions}. */
41
43
  constructor(options) {
42
44
  super({
@@ -47,6 +49,7 @@ export class AuthorizationCodeGrant extends AbstractGrant {
47
49
  this.requirePKCE = options.requirePKCE ?? true;
48
50
  this.requireClientAuthentication =
49
51
  options.requireClientAuthentication ?? true;
52
+ this.#onCodeReuse = options.onCodeReuse;
50
53
  }
51
54
  /**
52
55
  * Extracts client credentials from a request, carrying the PKCE
@@ -164,11 +167,27 @@ export class AuthorizationCodeGrant extends AbstractGrant {
164
167
  * Exchanges an authorization code for an access token. If PKCE was used
165
168
  * during authorization, the code_verifier must be provided.
166
169
  *
167
- * The code is claimed by revoking it before the client-match, verifier, and
168
- * `redirect_uri` checks, so a failed exchange still consumes the code. Only
169
- * the caller whose revoke wins receives a token, given an atomic
170
- * `authorizationCodeService.revoke`. Presenting a code that already minted
171
- * tokens asks `tokenService.revokeCode` to revoke those tokens and fails with
170
+ * A failed exchange still consumes the code: one that fails the
171
+ * client-match, verifier or `redirect_uri` check, whose token cannot be
172
+ * generated, or that `exchangeCode` refuses with an OAuth2 error, is claimed
173
+ * before the error is thrown. Any other error from `exchangeCode`, such as a
174
+ * lost database connection, leaves the code claimable. Only the caller whose
175
+ * claim wins receives a token, given an atomic
176
+ * `authorizationCodeService.revoke`.
177
+ *
178
+ * When the token service implements
179
+ * {@linkcode TokenServiceInterface.exchangeCode}, the grant claims the code
180
+ * and saves the token in that one call. Otherwise it claims the code with
181
+ * `authorizationCodeService.revoke` before its checks and saves the token
182
+ * with `tokenService.save` after them; a replay that lands between those
183
+ * two calls finds no token to revoke, so the token saved afterwards stays
184
+ * live.
185
+ *
186
+ * A code presented after it was claimed, whether it was gone when the
187
+ * request read it or a concurrent exchange won the claim, even one that
188
+ * then fails its checks, asks
189
+ * `tokenService.revokeCode` to revoke the tokens issued from it on behalf of
190
+ * the presenting client, fires `onCodeReuse` when it did, and fails with
172
191
  * `invalid_grant` (RFC 6819 §4.4.1.1).
173
192
  *
174
193
  * @throws {InvalidRequestError} When `code` is missing, a required `code_verifier` is absent, or the verifier is malformed.
@@ -183,17 +202,53 @@ export class AuthorizationCodeGrant extends AbstractGrant {
183
202
  if (typeof code !== "string") {
184
203
  throw new InvalidRequestError("code parameter required");
185
204
  }
186
- // RFC 6819 §4.4.1.1: code replay revokes already-issued tokens and rejects.
187
- if (await tokenService.revokeCode(code)) {
188
- throw new InvalidGrantError("code already used");
189
- }
190
205
  const authorizationCode = await authorizationCodeService.get(code);
191
- if (!authorizationCode || authorizationCode.expiresAt < new Date()) {
206
+ if (!authorizationCode) {
207
+ throw await this.#refuseClaimedCode(tokenService, code, client, "invalid code");
208
+ }
209
+ if (authorizationCode.expiresAt < new Date()) {
192
210
  throw new InvalidGrantError("invalid code");
193
211
  }
194
- if (!(await authorizationCodeService.revoke(authorizationCode))) {
195
- throw new InvalidGrantError("code already used");
212
+ const exchangeCode = tokenService.exchangeCode?.bind(tokenService);
213
+ if (!exchangeCode &&
214
+ !(await authorizationCodeService.revoke(authorizationCode))) {
215
+ throw await this.#refuseClaimedCode(tokenService, code, client);
216
+ }
217
+ let token;
218
+ try {
219
+ await this.#checkExchange(authorizationCode, client, body);
220
+ token = await this.generateToken(client, authorizationCode.user, authorizationCode.scope, tokenService, authorizationCode.authenticationContext);
221
+ }
222
+ catch (error) {
223
+ if (exchangeCode) {
224
+ await this.#consumeFailedExchange(authorizationCodeService, tokenService, authorizationCode, client);
225
+ }
226
+ throw error;
227
+ }
228
+ token.code = code;
229
+ let saved;
230
+ if (exchangeCode) {
231
+ try {
232
+ saved = await exchangeCode(authorizationCode, token);
233
+ }
234
+ catch (error) {
235
+ if (isOAuth2Error(error)) {
236
+ await this.#consumeFailedExchange(authorizationCodeService, tokenService, authorizationCode, client);
237
+ }
238
+ throw error;
239
+ }
240
+ }
241
+ else {
242
+ saved = await tokenService.save(token);
243
+ }
244
+ if (!saved) {
245
+ throw await this.#refuseClaimedCode(tokenService, code, client);
196
246
  }
247
+ if (authorizationCode.nonce)
248
+ saved.nonce = authorizationCode.nonce;
249
+ return saved;
250
+ }
251
+ async #checkExchange(authorizationCode, client, body) {
197
252
  const codeVerifier = body.get("code_verifier");
198
253
  if (typeof codeVerifier === "string") {
199
254
  if (!validateCodeVerifier(codeVerifier)) {
@@ -209,11 +264,11 @@ export class AuthorizationCodeGrant extends AbstractGrant {
209
264
  else if (this.requirePKCE) {
210
265
  throw new InvalidRequestError("PKCE is required for this grant");
211
266
  }
212
- const { client: codeClient, user, scope, redirectUri: expectedRedirectUri, } = authorizationCode;
213
- if (client.id.toString() !== codeClient.id.toString()) {
267
+ if (client.id.toString() !== authorizationCode.client.id.toString()) {
214
268
  throw new InvalidClientError("code was issued to another client");
215
269
  }
216
270
  const redirectUri = body.get("redirect_uri");
271
+ const expectedRedirectUri = authorizationCode.redirectUri;
217
272
  if (expectedRedirectUri) {
218
273
  if (typeof redirectUri !== "string") {
219
274
  throw new InvalidGrantError("redirect_uri parameter required");
@@ -225,11 +280,32 @@ export class AuthorizationCodeGrant extends AbstractGrant {
225
280
  else if (redirectUri) {
226
281
  throw new InvalidGrantError("did not expect redirect_uri parameter");
227
282
  }
228
- const token = await this.generateToken(client, user, scope, tokenService, authorizationCode.authenticationContext);
229
- token.code = code;
230
- const saved = await tokenService.save(token);
231
- if (authorizationCode.nonce)
232
- saved.nonce = authorizationCode.nonce;
233
- return saved;
283
+ }
284
+ async #consumeFailedExchange(authorizationCodeService, tokenService, authorizationCode, client) {
285
+ try {
286
+ if (!(await authorizationCodeService.revoke(authorizationCode))) {
287
+ await this.#refuseClaimedCode(tokenService, authorizationCode.code, client);
288
+ }
289
+ }
290
+ catch (error) {
291
+ console.error("[@udibo/oauth2] could not consume the code of a failed exchange:", error instanceof Error ? error.message : error);
292
+ }
293
+ }
294
+ async #refuseClaimedCode(tokenService, code, client, unrevokedMessage = "code already used") {
295
+ if (!(await tokenService.revokeCode(code, client))) {
296
+ return new InvalidGrantError(unrevokedMessage);
297
+ }
298
+ await this.#notifyCodeReuse({ codeHash: await sha256Hash(code), client });
299
+ return new InvalidGrantError("code already used");
300
+ }
301
+ async #notifyCodeReuse(event) {
302
+ if (!this.#onCodeReuse)
303
+ return;
304
+ try {
305
+ await this.#onCodeReuse(event);
306
+ }
307
+ catch (error) {
308
+ console.error("[@udibo/oauth2] onCodeReuse hook failed:", error instanceof Error ? error.message : error);
309
+ }
234
310
  }
235
311
  }
@@ -1,5 +1,6 @@
1
1
  import type { ClientInterface } from "../../models/client.ts";
2
2
  import type { AuthenticationContext } from "../../models/authentication.ts";
3
+ import type { AuthorizationCode } from "../../models/authorization-code.ts";
3
4
  import type { RefreshToken, Token } from "../../models/token.ts";
4
5
  import type { AbstractScope, BasicScope } from "../../models/scope.ts";
5
6
  import type { ClientServiceInterface } from "./client.ts";
@@ -156,16 +157,55 @@ export interface TokenServiceInterface<Client extends ClientInterface, User, Sco
156
157
  */
157
158
  rotate?(current: RefreshToken<Client, User, Scope>, next: RefreshToken<Client, User, Scope>): Promise<RefreshToken<Client, User, Scope> | undefined>;
158
159
  /**
159
- * Revokes all tokens issued with the given authorization code.
160
- * Returns true if any tokens were revoked (indicating code replay).
160
+ * Revokes all tokens issued with the given authorization code, called by the
161
+ * authorization code grant when a code is presented after it was claimed.
162
+ * Returns true if any tokens were revoked, which the grant reports as a code
163
+ * replay (RFC 6819 §4.4.1.1).
161
164
  *
162
- * Per RFC 6819 Section 4.4.1.1, if an authorization code is used more
163
- * than once, the authorization server MUST revoke all tokens previously
164
- * issued based on that authorization code.
165
+ * `client` is the client that presented the code. Revoke only when it is the
166
+ * client the code's tokens were issued to; for any other client revoke
167
+ * nothing and resolve `false`. A different confidential client could never
168
+ * have redeemed the code, since that needs the code's own client's
169
+ * credentials, so its replay says nothing about theft. The gate names a
170
+ * public client, or any client when the grant's
171
+ * `requireClientAuthentication` is `false`, by `client_id` alone, so anyone
172
+ * holding such a client's spent code can still present it as that client
173
+ * and revoke. A store that ignores `client` revokes on any client's replay,
174
+ * which lets a client that sees a leaked, spent code end the session it
175
+ * started.
176
+ *
177
+ * With {@linkcode exchangeCode}, make the two linearizable: a call that
178
+ * begins once `exchangeCode` has claimed the code must find and revoke the
179
+ * token it saved. Committing the claim and the insert together, as one
180
+ * database transaction does, meets this.
165
181
  *
166
182
  * @see https://datatracker.ietf.org/doc/html/rfc6819#section-4.4.1.1
167
183
  */
168
- revokeCode(code: string): Promise<boolean>;
184
+ revokeCode(code: string, client?: Client): Promise<boolean>;
185
+ /**
186
+ * Exchanges an authorization code in one atomic step: claims
187
+ * `authorizationCode` as `AuthorizationCodeServiceInterface.revoke`
188
+ * would, and persists `token`, the token issued from it, or does neither.
189
+ * Resolves the saved token, or `undefined` with nothing persisted when the
190
+ * code was already claimed or never stored. Concurrent calls for one code
191
+ * persist exactly one token. `token.code` is already set to the code.
192
+ *
193
+ * When implemented, the authorization code grant calls it instead of
194
+ * claiming the code through the authorization code service and saving the
195
+ * token with {@linkcode save}. Without it, the grant claims and saves in two
196
+ * calls, and a replay that arrives between them finds the code claimed but
197
+ * no token to revoke: the replay is refused, and the token the first
198
+ * exchange saves next stays live. With it, a replay finds the token
199
+ * {@linkcode revokeCode} can revoke.
200
+ *
201
+ * After a successful call the code service's `get` must resolve `undefined`
202
+ * for the code, as after `revoke`. A failed insert must leave the code
203
+ * claimable. Store the token so `revokeCode` finds it by the code, and make
204
+ * the two linearizable, as `revokeCode` requires.
205
+ * `runCodeExchangeContractTests` checks it under concurrent callers, with
206
+ * the store's barrier wired between its claim and its insert.
207
+ */
208
+ exchangeCode?(authorizationCode: AuthorizationCode<Client, User, Scope>, token: Token<Client, User, Scope>): Promise<Token<Client, User, Scope> | undefined>;
169
209
  /**
170
210
  * Looks up a refresh token that has already been revoked/rotated out. The
171
211
  * refresh grant's reuse detection reads it, and so does the revocation
@@ -188,8 +228,8 @@ export interface TokenServiceInterface<Client extends ClientInterface, User, Sco
188
228
  * given a token whose tombstone carries {@linkcode RefreshToken.rotatedAt}.
189
229
  * Returns true if anything was revoked.
190
230
  *
191
- * `/revoke` ends the family without firing `onTokenReuse`, so any audit
192
- * trail for that case has to come from this method.
231
+ * `/revoke` ends the family without firing `onTokenReuse`; it reports the
232
+ * revocation to `AuthorizationServerOptions.onRotatedOutRevocation` instead.
193
233
  *
194
234
  * Without {@linkcode rotate}, keep the family revoked: a successor that a
195
235
  * concurrent rotation saves after this call must be stored already revoked,
@@ -325,6 +365,8 @@ export declare abstract class AbstractTokenService<Client extends ClientInterfac
325
365
  /**
326
366
  * Revokes every token issued from the given authorization code, returning
327
367
  * true if any were revoked (signaling code replay per RFC 6819 §4.4.1.1).
368
+ * Revoke nothing when `client` is not the client the tokens were issued to
369
+ * ({@linkcode TokenServiceInterface.revokeCode} explains why).
328
370
  */
329
- abstract revokeCode(code: string): Promise<boolean>;
371
+ abstract revokeCode(code: string, client?: Client): Promise<boolean>;
330
372
  }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Contract test suite for {@link TokenServiceInterface.exchangeCode} and its
3
+ * linearizability with {@link TokenServiceInterface.revokeCode}.
4
+ *
5
+ * `runTokenServiceContractTests` checks `revokeCode` for one caller. This
6
+ * suite checks the atomic code exchange under concurrent callers: two
7
+ * exchanges of one code, a replay of the code racing its exchange through
8
+ * the authorization code grant, an exchange of an unrelated code, and an
9
+ * exchange whose insert fails. Each case asserts the store's end state, the
10
+ * live tokens and whether the code can still be claimed, not only the value
11
+ * each call resolved.
12
+ *
13
+ * The store under test awaits {@link CodeExchangeBarriers.afterClaim} inside
14
+ * `exchangeCode`, between its claim and its insert, so the suite can hold an
15
+ * exchange there and race the other call against it; a store that never
16
+ * reaches the barrier fails every held case. Only a store whose
17
+ * `exchangeCode` never awaits between its claim and its insert, such as
18
+ * `MemoryTokenService` paired with its code store, opts out with
19
+ * `exchangeAwaitsBarrier: false`; the suite then runs those schedules as
20
+ * overlapping calls.
21
+ *
22
+ * @module
23
+ */
24
+ import type { ClientInterface } from "../../models/client.ts";
25
+ import type { AbstractScope, BasicScope } from "../../models/scope.ts";
26
+ import type { AuthorizationCodeServiceInterface } from "../../server/services/authorization-code.ts";
27
+ import type { ClientServiceInterface } from "../../server/services/client.ts";
28
+ import type { TokenServiceInterface } from "../../server/services/token.ts";
29
+ import type { UserServiceInterface } from "../../server/services/user.ts";
30
+ import type { MemoryUserShape } from "../services.ts";
31
+ /**
32
+ * The point inside a store's {@linkcode TokenServiceInterface.exchangeCode}
33
+ * at which {@link runCodeExchangeContractTests} holds an exchange. Wire it
34
+ * into a test build of the store.
35
+ */
36
+ export interface CodeExchangeBarriers {
37
+ /**
38
+ * Await this inside `exchangeCode` after claiming the code and before
39
+ * persisting the token, still inside the atomic step: within the
40
+ * transaction and holding the code's lock. When it rejects, fail the
41
+ * exchange as a failed insert would: leave the code claimable, persist
42
+ * nothing, and reject or resolve `undefined`.
43
+ */
44
+ afterClaim(): Promise<void>;
45
+ }
46
+ /** Services built for each case of {@link runCodeExchangeContractTests}. */
47
+ export interface CodeExchangeContractServices<C extends ClientInterface, U extends MemoryUserShape, S extends AbstractScope = BasicScope> {
48
+ /** The user service both stores hydrate users from. */
49
+ userService: UserServiceInterface<U>;
50
+ /** The client service both stores hydrate clients from. */
51
+ clientService: ClientServiceInterface<C, U>;
52
+ /** The code store `exchangeCode` claims codes from. */
53
+ authorizationCodeService: AuthorizationCodeServiceInterface<C, U, S>;
54
+ /** The token service under test. It must implement `exchangeCode`. */
55
+ tokenService: TokenServiceInterface<C, U, S>;
56
+ }
57
+ /** Options for {@link runCodeExchangeContractTests}. */
58
+ export interface CodeExchangeContractOptions<C extends ClientInterface, U extends MemoryUserShape, S extends AbstractScope = BasicScope> {
59
+ /**
60
+ * Returns fresh services for each case, with `exchangeCode` awaiting
61
+ * `barriers.afterClaim()` between its claim and its insert. A store that
62
+ * sets `exchangeAwaitsBarrier: false` ignores `barriers`.
63
+ */
64
+ makeServices(barriers: CodeExchangeBarriers): Promise<CodeExchangeContractServices<C, U, S>> | CodeExchangeContractServices<C, U, S>;
65
+ /** Persists a user with the given password into the user service under test. */
66
+ addUser(service: UserServiceInterface<U>, user: U, password: string): Promise<void>;
67
+ /** Persists a client into the client service under test. */
68
+ addClient(service: ClientServiceInterface<C, U>, client: C): Promise<void>;
69
+ /** Builds a distinct user fixture for the given sequence number. Defaults to a minimal `{ id, username }` shape. */
70
+ makeUser?(seq: number): U;
71
+ /** Builds a distinct client fixture for the given sequence number. Defaults to a client granting `authorization_code` and `refresh_token`. */
72
+ makeClient?(seq: number): C;
73
+ /**
74
+ * Whether `exchangeCode` awaits `barriers.afterClaim()`. Defaults to
75
+ * `true`: the suite holds an exchange between its claim and its insert
76
+ * while it runs a second exchange, a replay through the grant, or an
77
+ * exchange of another code, and fails the insert there. Set `false` only
78
+ * when `exchangeCode` never awaits between its claim and its insert, as in
79
+ * `MemoryTokenService`; the suite then runs those schedules as overlapping
80
+ * calls, which cannot reach a gap inside `exchangeCode`.
81
+ */
82
+ exchangeAwaitsBarrier?: boolean;
83
+ /**
84
+ * Milliseconds the suite lets a call run against a held exchange before it
85
+ * releases the exchange; a call that has not settled by then is taken to be
86
+ * waiting on it. Defaults to 100. Raise it for a store whose calls take
87
+ * longer to reach the lock they wait on.
88
+ */
89
+ blockedAfterMs?: number;
90
+ /** Overrides the name passed to the outer `describe` block. */
91
+ describeName?: string;
92
+ }
93
+ /**
94
+ * Runs the code exchange contract suite: concurrent schedules of
95
+ * {@linkcode TokenServiceInterface.exchangeCode} against itself, and against
96
+ * a replay of the code through the authorization code grant, which calls
97
+ * {@linkcode TokenServiceInterface.revokeCode}. The service must implement
98
+ * `exchangeCode`.
99
+ */
100
+ export declare function runCodeExchangeContractTests<C extends ClientInterface, U extends MemoryUserShape, S extends AbstractScope = BasicScope>(options: CodeExchangeContractOptions<C, U, S>): void;
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Contract test suite for {@link TokenServiceInterface.exchangeCode} and its
3
+ * linearizability with {@link TokenServiceInterface.revokeCode}.
4
+ *
5
+ * `runTokenServiceContractTests` checks `revokeCode` for one caller. This
6
+ * suite checks the atomic code exchange under concurrent callers: two
7
+ * exchanges of one code, a replay of the code racing its exchange through
8
+ * the authorization code grant, an exchange of an unrelated code, and an
9
+ * exchange whose insert fails. Each case asserts the store's end state, the
10
+ * live tokens and whether the code can still be claimed, not only the value
11
+ * each call resolved.
12
+ *
13
+ * The store under test awaits {@link CodeExchangeBarriers.afterClaim} inside
14
+ * `exchangeCode`, between its claim and its insert, so the suite can hold an
15
+ * exchange there and race the other call against it; a store that never
16
+ * reaches the barrier fails every held case. Only a store whose
17
+ * `exchangeCode` never awaits between its claim and its insert, such as
18
+ * `MemoryTokenService` paired with its code store, opts out with
19
+ * `exchangeAwaitsBarrier: false`; the suite then runs those schedules as
20
+ * overlapping calls.
21
+ *
22
+ * @module
23
+ */
24
+ import { assert, beforeEach, describe, expect, it } from "vitest";
25
+ import { InvalidGrantError } from "../../errors.js";
26
+ import { AuthorizationCodeGrant, } from "../../server/grants/authorization-code.js";
27
+ import { race } from "./_race.js";
28
+ const defaultMakeUser = (seq) => ({ id: `u${seq}`, username: `user${seq}` });
29
+ const defaultMakeClient = (seq) => ({
30
+ id: `client-${seq}`,
31
+ grants: ["authorization_code", "refresh_token"],
32
+ });
33
+ async function settledWithin(promise, ms) {
34
+ let timer;
35
+ const timeout = new Promise((resolve) => {
36
+ timer = setTimeout(() => resolve(false), ms);
37
+ });
38
+ try {
39
+ return await Promise.race([
40
+ promise.then(() => true, () => true),
41
+ timeout,
42
+ ]);
43
+ }
44
+ finally {
45
+ clearTimeout(timer);
46
+ }
47
+ }
48
+ /**
49
+ * Runs the code exchange contract suite: concurrent schedules of
50
+ * {@linkcode TokenServiceInterface.exchangeCode} against itself, and against
51
+ * a replay of the code through the authorization code grant, which calls
52
+ * {@linkcode TokenServiceInterface.revokeCode}. The service must implement
53
+ * `exchangeCode`.
54
+ */
55
+ export function runCodeExchangeContractTests(options) {
56
+ const makeUser = options.makeUser ?? defaultMakeUser;
57
+ const makeClient = options.makeClient ?? defaultMakeClient;
58
+ const pausable = options.exchangeAwaitsBarrier ?? true;
59
+ const blockedAfterMs = options.blockedAfterMs ?? 100;
60
+ describe(options.describeName ?? "TokenServiceInterface.exchangeCode contract", () => {
61
+ let services;
62
+ let codeService;
63
+ let tokenService;
64
+ let user;
65
+ let client;
66
+ let armed;
67
+ const barriers = {
68
+ async afterClaim() {
69
+ const hold = armed;
70
+ if (!hold)
71
+ return;
72
+ armed = undefined;
73
+ hold.reached.resolve();
74
+ await hold.release.promise;
75
+ },
76
+ };
77
+ beforeEach(async () => {
78
+ armed = undefined;
79
+ services = await options.makeServices(barriers);
80
+ codeService = services.authorizationCodeService;
81
+ tokenService = services.tokenService;
82
+ user = makeUser(1);
83
+ client = makeClient(1);
84
+ await options.addUser(services.userService, user, "pw");
85
+ await options.addClient(services.clientService, client);
86
+ });
87
+ function exchangeCode(authorizationCode, token) {
88
+ assert(typeof tokenService.exchangeCode === "function", "runCodeExchangeContractTests needs a token service that implements exchangeCode");
89
+ return tokenService.exchangeCode(authorizationCode, token);
90
+ }
91
+ async function savedCode() {
92
+ const authorizationCode = {
93
+ code: await codeService.generateCode(client, user),
94
+ expiresAt: await codeService.expiresAt(client, user),
95
+ client,
96
+ user,
97
+ };
98
+ await codeService.save(authorizationCode);
99
+ return authorizationCode;
100
+ }
101
+ async function tokenFor(authorizationCode) {
102
+ return {
103
+ accessToken: await tokenService.generateAccessToken(client, user),
104
+ accessTokenExpiresAt: new Date(Date.now() + 60_000),
105
+ client,
106
+ user,
107
+ code: authorizationCode.code,
108
+ };
109
+ }
110
+ async function live(tokens) {
111
+ const found = [];
112
+ for (const token of tokens) {
113
+ if (await tokenService.getToken(token.accessToken)) {
114
+ found.push(token.accessToken);
115
+ }
116
+ }
117
+ return found;
118
+ }
119
+ async function claimable(authorizationCode) {
120
+ return (await codeService.get(authorizationCode.code)) !== undefined;
121
+ }
122
+ function arm() {
123
+ const hold = {
124
+ reached: Promise.withResolvers(),
125
+ release: Promise.withResolvers(),
126
+ };
127
+ armed = hold;
128
+ return hold;
129
+ }
130
+ async function untilHeld(hold, exchange) {
131
+ const outcome = await Promise.race([
132
+ hold.reached.promise.then(() => "held"),
133
+ exchange.then(() => "settled", () => "settled"),
134
+ ]);
135
+ assert(outcome === "held", "exchangeCode settled without awaiting barriers.afterClaim; wire " +
136
+ "the barrier into exchangeCode between its claim and its " +
137
+ "insert, or, only if exchangeCode never awaits between them, " +
138
+ "set exchangeAwaitsBarrier: false");
139
+ }
140
+ function codeGrant(reuseEvents) {
141
+ return new AuthorizationCodeGrant({
142
+ resolve: () => ({
143
+ clientService: services.clientService,
144
+ authorizationCodeService: codeService,
145
+ tokenService,
146
+ }),
147
+ requirePKCE: false,
148
+ onCodeReuse: (event) => {
149
+ reuseEvents.push(event);
150
+ },
151
+ });
152
+ }
153
+ function redeem(grant, authorizationCode) {
154
+ const body = new FormData();
155
+ body.set("grant_type", "authorization_code");
156
+ body.set("code", authorizationCode.code);
157
+ return grant.token(new Request("https://as.test/token", { method: "POST" }), client, body);
158
+ }
159
+ it("implements exchangeCode", () => {
160
+ assert(typeof tokenService.exchangeCode === "function", "the code exchange contract covers exchangeCode");
161
+ });
162
+ it("claims the code and saves its token in one call, findable by the code", async () => {
163
+ const authorizationCode = await savedCode();
164
+ const token = await tokenFor(authorizationCode);
165
+ const saved = await exchangeCode(authorizationCode, token);
166
+ expect(saved?.accessToken).toBe(token.accessToken);
167
+ expect(await claimable(authorizationCode), "an exchanged code must be spent").toBe(false);
168
+ expect(await codeService.revoke(authorizationCode.code)).toBe(false);
169
+ expect(await live([token])).toStrictEqual([token.accessToken]);
170
+ expect(await tokenService.revokeCode(authorizationCode.code, client), "revokeCode must find the token exchangeCode saved by its code").toBe(true);
171
+ expect(await live([token])).toStrictEqual([]);
172
+ });
173
+ it("refuses a code that was already claimed or never stored, persisting nothing", async () => {
174
+ const claimed = await savedCode();
175
+ await codeService.revoke(claimed.code);
176
+ const afterClaim = await tokenFor(claimed);
177
+ const unknown = {
178
+ ...claimed,
179
+ code: await codeService.generateCode(client, user),
180
+ };
181
+ const forUnknown = await tokenFor(unknown);
182
+ expect(await exchangeCode(claimed, afterClaim)).toBe(undefined);
183
+ expect(await exchangeCode(unknown, forUnknown)).toBe(undefined);
184
+ expect(await live([afterClaim, forUnknown])).toStrictEqual([]);
185
+ });
186
+ it("gives concurrent exchanges of one code exactly one token", async () => {
187
+ const authorizationCode = await savedCode();
188
+ const tokens = await race(() => tokenFor(authorizationCode));
189
+ const results = await race((caller) => exchangeCode(authorizationCode, tokens[caller]));
190
+ expect(results.filter((result) => result !== undefined).length, "exchangeCode must claim its code once, however many callers race").toBe(1);
191
+ expect(await live(tokens)).toHaveLength(1);
192
+ });
193
+ it("revokes the exchanged token when the code's own client replays it through the grant", async () => {
194
+ const authorizationCode = await savedCode();
195
+ const reuseEvents = [];
196
+ const grant = codeGrant(reuseEvents);
197
+ const issued = await redeem(grant, authorizationCode);
198
+ const replay = await Promise.allSettled([
199
+ redeem(grant, authorizationCode),
200
+ ]);
201
+ assert(replay[0].status === "rejected");
202
+ expect(replay[0].reason).toBeInstanceOf(InvalidGrantError);
203
+ expect(await live([issued])).toStrictEqual([]);
204
+ expect(reuseEvents).toHaveLength(1);
205
+ });
206
+ it("leaves no live token when the code is replayed through the grant while its exchange is in flight", async () => {
207
+ const authorizationCode = await savedCode();
208
+ const reuseEvents = [];
209
+ const grant = codeGrant(reuseEvents);
210
+ let outcomes;
211
+ if (pausable) {
212
+ const hold = arm();
213
+ const first = redeem(grant, authorizationCode);
214
+ await untilHeld(hold, first);
215
+ const replay = redeem(grant, authorizationCode);
216
+ await settledWithin(replay, blockedAfterMs);
217
+ hold.release.resolve();
218
+ outcomes = await Promise.allSettled([first, replay]);
219
+ }
220
+ else {
221
+ outcomes = await Promise.allSettled([
222
+ redeem(grant, authorizationCode),
223
+ redeem(grant, authorizationCode),
224
+ ]);
225
+ }
226
+ const issued = outcomes.flatMap((outcome) => outcome.status === "fulfilled" ? [outcome.value] : []);
227
+ const refused = outcomes.flatMap((outcome) => outcome.status === "rejected" ? [outcome.reason] : []);
228
+ expect(issued).toHaveLength(1);
229
+ expect(refused).toHaveLength(1);
230
+ expect(refused[0]).toBeInstanceOf(InvalidGrantError);
231
+ expect(await live(issued), "a replay that lands while the exchange is between its claim and " +
232
+ "its insert must still leave no live token issued from the code: " +
233
+ "revokeCode has to see the token, or the replay has to wait for " +
234
+ "the exchange and lose the claim").toStrictEqual([]);
235
+ expect(reuseEvents).toHaveLength(1);
236
+ });
237
+ it("refuses a second exchange that starts while the first is held after its claim", async ({ skip, }) => {
238
+ skip(!pausable, "exchangeCode cannot be held (exchangeAwaitsBarrier: false)");
239
+ const authorizationCode = await savedCode();
240
+ const first = await tokenFor(authorizationCode);
241
+ const second = await tokenFor(authorizationCode);
242
+ const hold = arm();
243
+ const held = exchangeCode(authorizationCode, first);
244
+ await untilHeld(hold, held);
245
+ const racing = exchangeCode(authorizationCode, second);
246
+ await settledWithin(racing, blockedAfterMs);
247
+ hold.release.resolve();
248
+ expect((await held)?.accessToken).toBe(first.accessToken);
249
+ expect(await racing).toBe(undefined);
250
+ expect(await live([first, second])).toStrictEqual([first.accessToken]);
251
+ });
252
+ it("does not hold an exchange of another code behind a held one", async ({ skip, }) => {
253
+ skip(!pausable, "exchangeCode cannot be held (exchangeAwaitsBarrier: false)");
254
+ const authorizationCode = await savedCode();
255
+ const token = await tokenFor(authorizationCode);
256
+ const otherCode = await savedCode();
257
+ const otherToken = await tokenFor(otherCode);
258
+ const hold = arm();
259
+ const exchange = exchangeCode(authorizationCode, token);
260
+ await untilHeld(hold, exchange);
261
+ const other = exchangeCode(otherCode, otherToken);
262
+ const settled = await settledWithin(other, 5_000);
263
+ hold.release.resolve();
264
+ expect(settled, "the code lock must be keyed by code, so one login's exchange " +
265
+ "never waits on another's").toBe(true);
266
+ expect((await other)?.accessToken).toBe(otherToken.accessToken);
267
+ expect((await exchange)?.accessToken).toBe(token.accessToken);
268
+ });
269
+ it("leaves the code claimable and persists nothing when the token cannot be persisted", async () => {
270
+ const authorizationCode = await savedCode();
271
+ const unpersistable = await tokenFor(authorizationCode);
272
+ const accessToken = unpersistable.accessToken;
273
+ Object.defineProperty(unpersistable, "accessToken", {
274
+ enumerable: true,
275
+ get() {
276
+ throw new Error("forced persistence failure");
277
+ },
278
+ });
279
+ const outcome = await Promise.allSettled([
280
+ exchangeCode(authorizationCode, unpersistable),
281
+ ]);
282
+ if (outcome[0].status === "fulfilled") {
283
+ expect(outcome[0].value).toBe(undefined);
284
+ }
285
+ expect(await claimable(authorizationCode), "a failed exchange must not spend its code").toBe(true);
286
+ expect(await tokenService.getToken(accessToken)).toBe(undefined);
287
+ const retry = await tokenFor(authorizationCode);
288
+ expect((await exchangeCode(authorizationCode, retry))?.accessToken).toBe(retry.accessToken);
289
+ });
290
+ it("rolls the claim back when the insert fails after it", async ({ skip, }) => {
291
+ skip(!pausable, "exchangeCode cannot be held (exchangeAwaitsBarrier: false)");
292
+ const authorizationCode = await savedCode();
293
+ const token = await tokenFor(authorizationCode);
294
+ const hold = arm();
295
+ const exchange = exchangeCode(authorizationCode, token);
296
+ await untilHeld(hold, exchange);
297
+ hold.release.reject(new Error("forced insert failure"));
298
+ const outcome = await Promise.allSettled([exchange]);
299
+ if (outcome[0].status === "fulfilled") {
300
+ expect(outcome[0].value).toBe(undefined);
301
+ }
302
+ expect(await claimable(authorizationCode), "the claim and the insert are one step: a failed insert must " +
303
+ "leave the code claimable").toBe(true);
304
+ expect(await live([token])).toStrictEqual([]);
305
+ const retry = await tokenFor(authorizationCode);
306
+ expect((await exchangeCode(authorizationCode, retry))?.accessToken, "a failed exchange must release the code").toBe(retry.accessToken);
307
+ });
308
+ });
309
+ }
@@ -30,6 +30,11 @@
30
30
  * claim and its insert wires `TokenRotationBarriers` into `rotate`, so the
31
31
  * suite can hold a rotation there.
32
32
  *
33
+ * `TokenServiceInterface.exchangeCode` has one too: two exchanges of one
34
+ * code, a replay racing an exchange through the authorization code grant,
35
+ * and an exchange whose insert fails. A store that awaits between its claim
36
+ * and its insert wires `CodeExchangeBarriers` into `exchangeCode`.
37
+ *
33
38
  * The service runners take a factory plus seed helpers because `add` /
34
39
  * `register` methods aren't part of the public service interfaces — the
35
40
  * consumer tells the runner how to populate test data.
@@ -40,6 +45,7 @@ export { runUserServiceContractTests, type UserServiceContractOptions, } from ".
40
45
  export { type ClientServiceContractOptions, runClientServiceContractTests, } from "./client.ts";
41
46
  export { runTokenServiceContractTests, type TokenServiceContractOptions, } from "./token.ts";
42
47
  export { runTokenRotationContractTests, type TokenRotationBarriers, type TokenRotationContractOptions, type TokenRotationContractServices, } from "./token-rotation.ts";
48
+ export { type CodeExchangeBarriers, type CodeExchangeContractOptions, type CodeExchangeContractServices, runCodeExchangeContractTests, } from "./code-exchange.ts";
43
49
  export { runTokenReaderContractTests, type TokenReaderContractFixture, type TokenReaderContractOptions, } from "./token-reader.ts";
44
50
  export { type AuthorizationCodeServiceContractOptions, runAuthorizationCodeServiceContractTests, } from "./authorization-code.ts";
45
51
  export { type DeviceAuthorizationServiceContractOptions, runDeviceAuthorizationServiceContractTests, } from "./device-authorization.ts";
@@ -30,6 +30,11 @@
30
30
  * claim and its insert wires `TokenRotationBarriers` into `rotate`, so the
31
31
  * suite can hold a rotation there.
32
32
  *
33
+ * `TokenServiceInterface.exchangeCode` has one too: two exchanges of one
34
+ * code, a replay racing an exchange through the authorization code grant,
35
+ * and an exchange whose insert fails. A store that awaits between its claim
36
+ * and its insert wires `CodeExchangeBarriers` into `exchangeCode`.
37
+ *
33
38
  * The service runners take a factory plus seed helpers because `add` /
34
39
  * `register` methods aren't part of the public service interfaces — the
35
40
  * consumer tells the runner how to populate test data.
@@ -40,6 +45,7 @@ export { runUserServiceContractTests, } from "./user.js";
40
45
  export { runClientServiceContractTests, } from "./client.js";
41
46
  export { runTokenServiceContractTests, } from "./token.js";
42
47
  export { runTokenRotationContractTests, } from "./token-rotation.js";
48
+ export { runCodeExchangeContractTests, } from "./code-exchange.js";
43
49
  export { runTokenReaderContractTests, } from "./token-reader.js";
44
50
  export { runAuthorizationCodeServiceContractTests, } from "./authorization-code.js";
45
51
  export { runDeviceAuthorizationServiceContractTests, } from "./device-authorization.js";
@@ -78,6 +78,14 @@ export interface TokenServiceContractOptions<C extends ClientInterface, U extend
78
78
  * loudly rather than losing reuse detection quietly.
79
79
  */
80
80
  refreshTokenReuseDetection?: boolean;
81
+ /**
82
+ * Set to `false` when the service's `revokeCode` deliberately ignores its
83
+ * `client` argument. The suite then registers an ignored case naming what is
84
+ * not covered — any client that presents a spent code revokes the tokens
85
+ * issued from it, so a client that sees a leaked code can end the session it
86
+ * started — instead of passing in silence. Defaults to `true`.
87
+ */
88
+ revokeCodeChecksClient?: boolean;
81
89
  /** Overrides the name passed to the outer `describe` block. */
82
90
  describeName?: string;
83
91
  }
@@ -35,6 +35,7 @@ export function runTokenServiceContractTests(options) {
35
35
  const supportedScope = options.supportedScope ?? "read";
36
36
  const makeFamilyId = options.makeFamilyId ?? defaultMakeFamilyId;
37
37
  const expectReuseDetection = options.refreshTokenReuseDetection ?? true;
38
+ const expectCodeClientCheck = options.revokeCodeChecksClient ?? true;
38
39
  describe(options.describeName ?? "TokenServiceInterface contract", () => {
39
40
  let userService;
40
41
  let clientService;
@@ -214,6 +215,32 @@ export function runTokenServiceContractTests(options) {
214
215
  it("returns false when no tokens were issued for the code", async () => {
215
216
  expect(await tokenService.revokeCode("never-used")).toBe(false);
216
217
  });
218
+ if (expectCodeClientCheck) {
219
+ async function issueFromCode(code) {
220
+ const accessToken = await tokenService.generateAccessToken(client, user);
221
+ await tokenService.save({ accessToken, code, client, user });
222
+ return accessToken;
223
+ }
224
+ it("revokes nothing for a client other than the one the code's tokens were issued to", async () => {
225
+ const other = makeClient(2);
226
+ await options.addClient(clientService, other);
227
+ const accessToken = await issueFromCode("replayed-code");
228
+ expect(await tokenService.revokeCode("replayed-code", other), "a client that could never have redeemed the code must not " +
229
+ "revoke what it issued").toBe(false);
230
+ expect(await tokenService.getToken(accessToken)).not.toBe(undefined);
231
+ });
232
+ it("revokes for the client the code's tokens were issued to, after another client was refused", async () => {
233
+ const other = makeClient(2);
234
+ await options.addClient(clientService, other);
235
+ const accessToken = await issueFromCode("replayed-code");
236
+ await tokenService.revokeCode("replayed-code", other);
237
+ expect(await tokenService.revokeCode("replayed-code", client)).toBe(true);
238
+ expect(await tokenService.getToken(accessToken)).toBe(undefined);
239
+ });
240
+ }
241
+ else {
242
+ it.skip("revokeCode ignores the presenting client — any client that presents a spent code revokes its tokens (revokeCodeChecksClient: false)", () => { });
243
+ }
217
244
  });
218
245
  if (expectReuseDetection) {
219
246
  describe("refresh-token rotation family", () => {
@@ -78,13 +78,14 @@ export async function createMemoryAuthorizationServer(options = {}) {
78
78
  const enabledGrants = { ...DEFAULT_GRANTS, ...options.grants };
79
79
  const userService = new MemoryUserService();
80
80
  const clientService = new MemoryClientService(userService);
81
- const tokenService = new MemoryTokenService({
81
+ const authorizationCodeService = new MemoryAuthorizationCodeService({
82
82
  clientService,
83
83
  userService,
84
84
  });
85
- const authorizationCodeService = new MemoryAuthorizationCodeService({
85
+ const tokenService = new MemoryTokenService({
86
86
  clientService,
87
87
  userService,
88
+ authorizationCodeService,
88
89
  });
89
90
  const deviceAuthorizationService = new MemoryDeviceAuthorizationService({ clientService, userService });
90
91
  const grants = {};
@@ -135,7 +135,7 @@ export declare class MemoryClientService<C extends ClientInterface, U> implement
135
135
  getUser(client: C | string): Promise<U | undefined>;
136
136
  }
137
137
  /** Options for {@link MemoryTokenService}. */
138
- export interface MemoryTokenServiceOptions<C extends ClientInterface, U extends MemoryUserShape> {
138
+ export interface MemoryTokenServiceOptions<C extends ClientInterface, U extends MemoryUserShape, S extends AbstractScope = BasicScope> {
139
139
  /** Resolves clients when hydrating stored tokens back into full records. */
140
140
  clientService: ClientServiceInterface<C, U>;
141
141
  /** Resolves users when hydrating stored tokens back into full records. */
@@ -149,6 +149,14 @@ export interface MemoryTokenServiceOptions<C extends ClientInterface, U extends
149
149
  * leave rotation uncapped.
150
150
  */
151
151
  refreshTokenMaxLifetime?: number;
152
+ /**
153
+ * The code store the authorization code grant claims codes from. Pass it to
154
+ * give the token service {@linkcode MemoryTokenService.exchangeCode}, so the
155
+ * grant claims a code and saves its token in one step; without it the grant
156
+ * claims and saves in two. `exchangeCode` claims from the code store's own
157
+ * map, so a subclass that overrides the code store's `revoke` is bypassed.
158
+ */
159
+ authorizationCodeService?: MemoryAuthorizationCodeService<C, U, S>;
152
160
  }
153
161
  /**
154
162
  * In-memory `TokenServiceInterface` implementation. Tokens are hashed on
@@ -171,13 +179,21 @@ export declare class MemoryTokenService<C extends ClientInterface, U extends Mem
171
179
  * client sets none. Undefined leaves families uncapped.
172
180
  */
173
181
  refreshTokenMaxLifetime?: number;
182
+ /**
183
+ * Claims a code from the paired `authorizationCodeService` and saves the
184
+ * token issued from it in one step, or does neither, resolving `undefined`
185
+ * when the code was already claimed. Present only when the service was
186
+ * constructed with `authorizationCodeService`, so the grant falls back to
187
+ * two calls for a token service that has no code store to claim from.
188
+ */
189
+ readonly exchangeCode?: (authorizationCode: AuthorizationCode<C, U, S>, token: Token<C, U, S>) => Promise<Token<C, U, S> | undefined>;
174
190
  /**
175
191
  * Creates an empty token store.
176
192
  *
177
- * @param options Backing client/user services and optional default token
178
- * lifetimes.
193
+ * @param options Backing client/user services, optional default token
194
+ * lifetimes, and the code store that enables `exchangeCode`.
179
195
  */
180
- constructor(options: MemoryTokenServiceOptions<C, U>);
196
+ constructor(options: MemoryTokenServiceOptions<C, U, S>);
181
197
  /**
182
198
  * Accepts the requested scope verbatim; the stub performs no scope
183
199
  * narrowing. Return `false` from a subclass to reject a request.
@@ -279,8 +295,10 @@ export declare class MemoryTokenService<C extends ClientInterface, U extends Mem
279
295
  /**
280
296
  * Removes every token issued from the given authorization code, used when a
281
297
  * code is replayed. Resolves `true` when at least one token was removed.
298
+ * Removes nothing and resolves `false` when `client` is given and is not the
299
+ * client the code's tokens were issued to.
282
300
  */
283
- revokeCode(code: string): Promise<boolean>;
301
+ revokeCode(code: string, client?: C): Promise<boolean>;
284
302
  }
285
303
  /** Options for {@link MemoryAuthorizationCodeService}. */
286
304
  export interface MemoryAuthorizationCodeServiceOptions<C extends ClientInterface, U extends MemoryUserShape> {
@@ -208,11 +208,19 @@ export class MemoryTokenService {
208
208
  #tokensByCodeHash = new Map();
209
209
  #revokedByRefreshHash = new Map();
210
210
  #revokedFamilies = new Set();
211
+ /**
212
+ * Claims a code from the paired `authorizationCodeService` and saves the
213
+ * token issued from it in one step, or does neither, resolving `undefined`
214
+ * when the code was already claimed. Present only when the service was
215
+ * constructed with `authorizationCodeService`, so the grant falls back to
216
+ * two calls for a token service that has no code store to claim from.
217
+ */
218
+ exchangeCode;
211
219
  /**
212
220
  * Creates an empty token store.
213
221
  *
214
- * @param options Backing client/user services and optional default token
215
- * lifetimes.
222
+ * @param options Backing client/user services, optional default token
223
+ * lifetimes, and the code store that enables `exchangeCode`.
216
224
  */
217
225
  constructor(options) {
218
226
  this.#clientService = options.clientService;
@@ -221,6 +229,21 @@ export class MemoryTokenService {
221
229
  this.refreshTokenLifetime =
222
230
  options.refreshTokenLifetime ?? 14 * 24 * 60 * 60;
223
231
  this.refreshTokenMaxLifetime = options.refreshTokenMaxLifetime;
232
+ const codes = options.authorizationCodeService;
233
+ if (codes) {
234
+ this.exchangeCode = (authorizationCode, token) => this.#exchangeCode(codes, authorizationCode, token);
235
+ }
236
+ }
237
+ async #exchangeCode(codes, authorizationCode, token) {
238
+ const codeHash = await sha256Hash(authorizationCode.code);
239
+ const stored = await this.#prepare({
240
+ ...token,
241
+ code: authorizationCode.code,
242
+ });
243
+ if (!claimStoredCode(codes, codeHash))
244
+ return undefined;
245
+ this.#store(stored);
246
+ return token;
224
247
  }
225
248
  /**
226
249
  * Accepts the requested scope verbatim; the stub performs no scope
@@ -344,10 +367,10 @@ export class MemoryTokenService {
344
367
  if (stored.codeHash) {
345
368
  let bucket = this.#tokensByCodeHash.get(stored.codeHash);
346
369
  if (!bucket) {
347
- bucket = new Set();
370
+ bucket = { clientId: stored.clientId, accessTokenHashes: new Set() };
348
371
  this.#tokensByCodeHash.set(stored.codeHash, bucket);
349
372
  }
350
- bucket.add(stored.accessTokenHash);
373
+ bucket.accessTokenHashes.add(stored.accessTokenHash);
351
374
  }
352
375
  if (stored.refreshTokenHash) {
353
376
  if (stored.familyId && this.#revokedFamilies.has(stored.familyId)) {
@@ -481,13 +504,18 @@ export class MemoryTokenService {
481
504
  /**
482
505
  * Removes every token issued from the given authorization code, used when a
483
506
  * code is replayed. Resolves `true` when at least one token was removed.
507
+ * Removes nothing and resolves `false` when `client` is given and is not the
508
+ * client the code's tokens were issued to.
484
509
  */
485
- async revokeCode(code) {
510
+ async revokeCode(code, client) {
486
511
  const codeHash = await sha256Hash(code);
487
- const tokenHashes = this.#tokensByCodeHash.get(codeHash);
488
- if (!tokenHashes || tokenHashes.size === 0)
512
+ const bucket = this.#tokensByCodeHash.get(codeHash);
513
+ if (!bucket || bucket.accessTokenHashes.size === 0)
514
+ return false;
515
+ if (client && client.id.toString() !== bucket.clientId.toString()) {
489
516
  return false;
490
- for (const accessTokenHash of tokenHashes) {
517
+ }
518
+ for (const accessTokenHash of bucket.accessTokenHashes) {
491
519
  const stored = this.#tokensByAccessHash.get(accessTokenHash);
492
520
  if (stored) {
493
521
  this.#tokensByAccessHash.delete(accessTokenHash);
@@ -500,6 +528,7 @@ export class MemoryTokenService {
500
528
  return true;
501
529
  }
502
530
  }
531
+ let claimStoredCode;
503
532
  /** In-memory `AuthorizationCodeServiceInterface` implementation. */
504
533
  export class MemoryAuthorizationCodeService {
505
534
  /** Authorization-code lifetime in seconds applied by {@linkcode expiresAt}. */
@@ -507,6 +536,9 @@ export class MemoryAuthorizationCodeService {
507
536
  #clientService;
508
537
  #userService;
509
538
  #codes = new Map();
539
+ static {
540
+ claimStoredCode = (service, codeHash) => service.#codes.delete(codeHash);
541
+ }
510
542
  /**
511
543
  * Creates an empty authorization-code store.
512
544
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@udibo/oauth2",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
4
  "description": "A flexible OAuth2 toolkit: framework-agnostic authorization & resource servers, a typed client, Hono adapters (including a backend-for-frontend), and React bindings.",
5
5
  "keywords": [
6
6
  "authorization-server",