@udibo/oauth2 0.33.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.
@@ -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;