@udibo/oauth2 0.36.0 → 0.38.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.
- package/README.md +1 -1
- package/dist/identity/events.d.ts +28 -7
- package/dist/identity/mfa/service.d.ts +6 -2
- package/dist/identity/mfa/service.js +8 -5
- package/dist/identity/service.d.ts +43 -11
- package/dist/identity/service.js +82 -40
- package/dist/models/token.d.ts +8 -0
- package/dist/server/grants/authorization-code.d.ts +15 -4
- package/dist/server/grants/authorization-code.js +38 -11
- package/dist/server/services/token.d.ts +19 -13
- package/dist/testing/contract/code-exchange.d.ts +9 -0
- package/dist/testing/contract/code-exchange.js +45 -1
- package/dist/testing/contract/identity-user-store.d.ts +18 -0
- package/dist/testing/contract/identity-user-store.js +54 -0
- package/dist/testing/contract/token.d.ts +9 -0
- package/dist/testing/contract/token.js +68 -0
- package/dist/testing/services.d.ts +3 -2
- package/dist/testing/services.js +11 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @udibo/oauth2
|
|
2
2
|
|
|
3
|
-
[](https://www.npmjs.com/package/@udibo/oauth2) [](https://jsr.io/@udibo/oauth2) [](https://www.npmjs.com/package/@udibo/oauth2) [](https://jsr.io/@udibo/oauth2) [](https://github.com/udibo/oauth2/actions/workflows/ci-cd.yml?query=branch%3Amain) [](https://codecov.io/gh/udibo/oauth2) [](LICENSE) [](https://www.npmjs.com/package/@udibo/oauth2) [](https://www.npmjs.com/package/@udibo/oauth2)
|
|
4
4
|
|
|
5
5
|
OAuth2 and OpenID Connect tools for TypeScript applications: clients,
|
|
6
6
|
authorization and resource servers, Hono middleware, React bindings, and
|
|
@@ -118,18 +118,39 @@ export type IdentityEvent = {
|
|
|
118
118
|
} | {
|
|
119
119
|
type: "password_reset.completed";
|
|
120
120
|
userId: string;
|
|
121
|
+
/**
|
|
122
|
+
* Sessions the reset revoked: the sum of what
|
|
123
|
+
* `IdentityUserStore.resetCredential` and the `sessions` service
|
|
124
|
+
* reported. Absent when neither ran.
|
|
125
|
+
*/
|
|
126
|
+
sessionsRevoked?: number;
|
|
121
127
|
} | {
|
|
122
128
|
type: "password_reset.failed";
|
|
123
129
|
/**
|
|
124
130
|
* `"invalid_token"` — the reset link was unknown, expired, or already used.
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
+
* Nothing changed.
|
|
132
|
+
*
|
|
133
|
+
* The other reasons fire after the link was spent, and the error is
|
|
134
|
+
* rethrown:
|
|
135
|
+
*
|
|
136
|
+
* - `"credential_write_failed"` — hashing or storing the new password
|
|
137
|
+
* threw. The old password still works and no session was revoked; the
|
|
138
|
+
* user requests a fresh link.
|
|
139
|
+
* - `"session_revocation_failed"` — the new password was set but the
|
|
140
|
+
* configured `RevocableSessionService` threw before the user's other
|
|
141
|
+
* sessions could be revoked. Call `revokeAllByUser(userId)` again.
|
|
142
|
+
* - `"lockout_reset_failed"` — the password changed and the sessions were
|
|
143
|
+
* revoked, but clearing the failed-attempt lockout threw. Call
|
|
144
|
+
* `reset(userId)` on the lockout, or let the lock lapse.
|
|
145
|
+
*
|
|
146
|
+
* A store that implements `IdentityUserStore.resetCredential` writes the
|
|
147
|
+
* password and revokes the sessions it holds together. A
|
|
148
|
+
* `"session_revocation_failed"` after that means the stored sessions
|
|
149
|
+
* were revoked and only the `sessions` service's own state was not.
|
|
131
150
|
*/
|
|
132
|
-
reason: "invalid_token" | "session_revocation_failed";
|
|
151
|
+
reason: "invalid_token" | "credential_write_failed" | "session_revocation_failed" | "lockout_reset_failed";
|
|
152
|
+
/** The account the spent link belonged to; absent for `"invalid_token"`. */
|
|
153
|
+
userId?: string;
|
|
133
154
|
} | {
|
|
134
155
|
type: "credential_mint.failed";
|
|
135
156
|
/**
|
|
@@ -189,7 +189,9 @@ export type MfaVerification = {
|
|
|
189
189
|
method: "recovery";
|
|
190
190
|
/**
|
|
191
191
|
* How many unused recovery codes remain — surface it so the UI can
|
|
192
|
-
* prompt regeneration when low.
|
|
192
|
+
* prompt regeneration when low. Counted from the set read just before
|
|
193
|
+
* this code was burned, so a code burned concurrently is not yet
|
|
194
|
+
* subtracted.
|
|
193
195
|
*/
|
|
194
196
|
remainingRecoveryCodes: number;
|
|
195
197
|
};
|
|
@@ -285,7 +287,9 @@ export declare class MfaService {
|
|
|
285
287
|
* accepted TOTP code advances the stored last step through
|
|
286
288
|
* {@link MfaStore.advanceLastStep}, so the same code is rejected on replay;
|
|
287
289
|
* an accepted recovery code is burned through
|
|
288
|
-
* {@link MfaStore.consumeRecoveryHash}, so it is single-use.
|
|
290
|
+
* {@link MfaStore.consumeRecoveryHash}, so it is single-use. The remaining
|
|
291
|
+
* codes are read before the burn, so a store failure never spends a code
|
|
292
|
+
* without signing the user in. Both hold under
|
|
289
293
|
* concurrent submission only if the store implements those methods
|
|
290
294
|
* atomically. Without an
|
|
291
295
|
* active credential every code is rejected — recovery codes are a fallback
|
|
@@ -153,7 +153,9 @@ export class MfaService {
|
|
|
153
153
|
* accepted TOTP code advances the stored last step through
|
|
154
154
|
* {@link MfaStore.advanceLastStep}, so the same code is rejected on replay;
|
|
155
155
|
* an accepted recovery code is burned through
|
|
156
|
-
* {@link MfaStore.consumeRecoveryHash}, so it is single-use.
|
|
156
|
+
* {@link MfaStore.consumeRecoveryHash}, so it is single-use. The remaining
|
|
157
|
+
* codes are read before the burn, so a store failure never spends a code
|
|
158
|
+
* without signing the user in. Both hold under
|
|
157
159
|
* concurrent submission only if the store implements those methods
|
|
158
160
|
* atomically. Without an
|
|
159
161
|
* active credential every code is rejected — recovery codes are a fallback
|
|
@@ -255,11 +257,12 @@ export class MfaService {
|
|
|
255
257
|
if (normalizeRecoveryCode(code).length !== RECOVERY_CODE_LENGTH) {
|
|
256
258
|
return undefined;
|
|
257
259
|
}
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
+
const hash = await hashRecoveryCode(code);
|
|
261
|
+
const stored = await this.#store.getRecoveryHashes(userId);
|
|
262
|
+
if (!(await this.#store.consumeRecoveryHash(userId, hash))) {
|
|
260
263
|
return undefined;
|
|
261
|
-
|
|
262
|
-
|
|
264
|
+
}
|
|
265
|
+
const remainingRecoveryCodes = stored.filter((candidate) => candidate !== hash).length;
|
|
263
266
|
await this.#resetThrottle(userId);
|
|
264
267
|
await this.#emit({
|
|
265
268
|
type: "mfa.verify.succeeded",
|
|
@@ -60,6 +60,22 @@ export interface IdentityUserStore<User extends IdentityUser> {
|
|
|
60
60
|
getCredential(userId: string): Promise<PasswordCredential | undefined>;
|
|
61
61
|
/** Replace the user's password credential (reset / change). */
|
|
62
62
|
setCredential(userId: string, credential: PasswordCredential): Promise<void>;
|
|
63
|
+
/**
|
|
64
|
+
* Complete a password reset in one transaction: replace the credential,
|
|
65
|
+
* drop any imported hash, and revoke **every** session of the user. Return
|
|
66
|
+
* the number of sessions revoked. On a throw, none of the three may have
|
|
67
|
+
* happened.
|
|
68
|
+
*
|
|
69
|
+
* Optional. Implement it when the credential and the sessions share a
|
|
70
|
+
* database, so a session-store outage cannot leave a reset with the new
|
|
71
|
+
* password set and the old sessions live. When present,
|
|
72
|
+
* {@link IdentityService.resetPassword} calls it instead of
|
|
73
|
+
* {@link setCredential} and {@link clearLegacyCredential}. The reset link is
|
|
74
|
+
* still spent first. A configured `sessions` service is still called after
|
|
75
|
+
* this commits, for session state outside the transaction, such as a cache.
|
|
76
|
+
* Its `revokeAllByUser` then finds nothing left in the shared database.
|
|
77
|
+
*/
|
|
78
|
+
resetCredential?(userId: string, credential: PasswordCredential): Promise<number>;
|
|
63
79
|
/**
|
|
64
80
|
* Atomically replace the credential only if it still equals `expected`.
|
|
65
81
|
* `undefined` expects no native credential, for an imported-password upgrade.
|
|
@@ -188,7 +204,13 @@ export interface IdentityServiceOptions<User extends IdentityUser> {
|
|
|
188
204
|
* Successful sign-ins are never delayed.
|
|
189
205
|
*/
|
|
190
206
|
failedSignInFloorMs?: number;
|
|
191
|
-
/**
|
|
207
|
+
/**
|
|
208
|
+
* Session revocation, called on a successful password reset. Optional. When
|
|
209
|
+
* the user store implements {@link IdentityUserStore.resetCredential}, the
|
|
210
|
+
* reset still calls `revokeAllByUser` after that transaction commits, so
|
|
211
|
+
* session state the transaction cannot reach is revoked too. Omit it when
|
|
212
|
+
* the store's transaction already covers every place a session lives.
|
|
213
|
+
*/
|
|
192
214
|
sessions?: RevocableSessionService;
|
|
193
215
|
/** Delivery transports for the emailed links and sign-in codes. Optional. */
|
|
194
216
|
delivery?: DeliveryHooks;
|
|
@@ -468,19 +490,27 @@ export declare class IdentityService<User extends IdentityUser> {
|
|
|
468
490
|
* redeemable until they expire) and, when an `otp` service is configured, the
|
|
469
491
|
* pending sign-in code. Neither can fail the reset: a store that throws is
|
|
470
492
|
* logged and the reset still completes, since the password has already
|
|
471
|
-
* changed by then.
|
|
493
|
+
* changed by then. Each is voided independently, so one store failing does
|
|
494
|
+
* not keep the other credential alive.
|
|
472
495
|
*
|
|
473
496
|
* The token is consumed **first**, before the password is written, so two
|
|
474
497
|
* concurrent submissions of one link resolve to exactly one `{ userId }` and
|
|
475
498
|
* only that call's password is set — the loser gets `null` and writes
|
|
476
|
-
* nothing.
|
|
477
|
-
*
|
|
478
|
-
* (`session_revocation_failed`
|
|
479
|
-
* fires,
|
|
480
|
-
*
|
|
481
|
-
* the
|
|
482
|
-
*
|
|
483
|
-
*
|
|
499
|
+
* nothing. Any failure after that point is reported, never swallowed: a
|
|
500
|
+
* `password_reset.failed` event carrying the `userId` and a reason
|
|
501
|
+
* (`credential_write_failed`, `session_revocation_failed` or
|
|
502
|
+
* `lockout_reset_failed`) fires, no `password_reset.completed` fires, and the
|
|
503
|
+
* error rethrows. Each reason names what already changed and how to finish
|
|
504
|
+
* the reset; see the event's documentation.
|
|
505
|
+
*
|
|
506
|
+
* Without {@link IdentityUserStore.resetCredential}, sessions are revoked
|
|
507
|
+
* **after** the password is changed, in a separate call, so a session-store
|
|
508
|
+
* outage leaves the new password set and the old sessions live. A store
|
|
509
|
+
* that implements it makes the password write and the revocation one
|
|
510
|
+
* transaction; a configured `sessions` service then runs after the commit
|
|
511
|
+
* and can still report `session_revocation_failed` for the state outside
|
|
512
|
+
* it. `password_reset.completed` carries `sessionsRevoked`, the sum of the
|
|
513
|
+
* counts the store and the `sessions` service reported.
|
|
484
514
|
*
|
|
485
515
|
* @throws {IdentityError} `weak_password` when `password` fails the password
|
|
486
516
|
* policy — including the 8–256 character defaults that apply when
|
|
@@ -602,7 +632,9 @@ export declare class IdentityService<User extends IdentityUser> {
|
|
|
602
632
|
* the `signin_code.failed` event carries `locked`. Success consumes the code
|
|
603
633
|
* (single-use), resets the flow's throttle window, and carries the `userId`
|
|
604
634
|
* to sign in — session creation and your MFA gate stay your app's job, as
|
|
605
|
-
* does treating a since-deleted/disabled user as a failed sign-in.
|
|
635
|
+
* does treating a since-deleted/disabled user as a failed sign-in. A
|
|
636
|
+
* throttle reset that throws is logged and the result is still `success`,
|
|
637
|
+
* since the code is already spent.
|
|
606
638
|
*/
|
|
607
639
|
verifySignInCode(input: {
|
|
608
640
|
email: string;
|
package/dist/identity/service.js
CHANGED
|
@@ -258,19 +258,27 @@ export class IdentityService {
|
|
|
258
258
|
* redeemable until they expire) and, when an `otp` service is configured, the
|
|
259
259
|
* pending sign-in code. Neither can fail the reset: a store that throws is
|
|
260
260
|
* logged and the reset still completes, since the password has already
|
|
261
|
-
* changed by then.
|
|
261
|
+
* changed by then. Each is voided independently, so one store failing does
|
|
262
|
+
* not keep the other credential alive.
|
|
262
263
|
*
|
|
263
264
|
* The token is consumed **first**, before the password is written, so two
|
|
264
265
|
* concurrent submissions of one link resolve to exactly one `{ userId }` and
|
|
265
266
|
* only that call's password is set — the loser gets `null` and writes
|
|
266
|
-
* nothing.
|
|
267
|
-
*
|
|
268
|
-
* (`session_revocation_failed`
|
|
269
|
-
* fires,
|
|
270
|
-
*
|
|
271
|
-
* the
|
|
272
|
-
*
|
|
273
|
-
*
|
|
267
|
+
* nothing. Any failure after that point is reported, never swallowed: a
|
|
268
|
+
* `password_reset.failed` event carrying the `userId` and a reason
|
|
269
|
+
* (`credential_write_failed`, `session_revocation_failed` or
|
|
270
|
+
* `lockout_reset_failed`) fires, no `password_reset.completed` fires, and the
|
|
271
|
+
* error rethrows. Each reason names what already changed and how to finish
|
|
272
|
+
* the reset; see the event's documentation.
|
|
273
|
+
*
|
|
274
|
+
* Without {@link IdentityUserStore.resetCredential}, sessions are revoked
|
|
275
|
+
* **after** the password is changed, in a separate call, so a session-store
|
|
276
|
+
* outage leaves the new password set and the old sessions live. A store
|
|
277
|
+
* that implements it makes the password write and the revocation one
|
|
278
|
+
* transaction; a configured `sessions` service then runs after the commit
|
|
279
|
+
* and can still report `session_revocation_failed` for the state outside
|
|
280
|
+
* it. `password_reset.completed` carries `sessionsRevoked`, the sum of the
|
|
281
|
+
* counts the store and the `sessions` service reported.
|
|
274
282
|
*
|
|
275
283
|
* @throws {IdentityError} `weak_password` when `password` fails the password
|
|
276
284
|
* policy — including the 8–256 character defaults that apply when
|
|
@@ -293,33 +301,55 @@ export class IdentityService {
|
|
|
293
301
|
});
|
|
294
302
|
if (consumed.status !== "success")
|
|
295
303
|
return null;
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
}
|
|
304
|
-
await this.#voidPasswordlessCredentials(tokens, resolved.subject, emailFromData(resolved.data));
|
|
305
|
-
if (this.#sessions) {
|
|
306
|
-
try {
|
|
307
|
-
await this.#sessions.revokeAllByUser(resolved.subject);
|
|
304
|
+
const userId = consumed.resolved.subject;
|
|
305
|
+
const resetCredential = this.#users.resetCredential;
|
|
306
|
+
let sessionsRevoked;
|
|
307
|
+
await this.#failResetOnThrow(userId, "credential_write_failed", async () => {
|
|
308
|
+
const credential = await this.#passwords.hash(input.password);
|
|
309
|
+
if (resetCredential) {
|
|
310
|
+
sessionsRevoked = await resetCredential.call(this.#users, userId, credential);
|
|
308
311
|
}
|
|
309
|
-
|
|
310
|
-
await this.#
|
|
311
|
-
type: "password_reset.failed",
|
|
312
|
-
reason: "session_revocation_failed",
|
|
313
|
-
});
|
|
314
|
-
throw error;
|
|
312
|
+
else {
|
|
313
|
+
await this.#users.setCredential(userId, credential);
|
|
315
314
|
}
|
|
315
|
+
});
|
|
316
|
+
if (!resetCredential)
|
|
317
|
+
await this.#clearLegacyAfterReset(userId);
|
|
318
|
+
await this.#voidPasswordlessCredentials(tokens, userId, emailFromData(consumed.resolved.data));
|
|
319
|
+
const sessions = this.#sessions;
|
|
320
|
+
if (sessions) {
|
|
321
|
+
await this.#failResetOnThrow(userId, "session_revocation_failed", async () => {
|
|
322
|
+
sessionsRevoked =
|
|
323
|
+
(sessionsRevoked ?? 0) + (await sessions.revokeAllByUser(userId));
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
const lockout = this.#lockout;
|
|
327
|
+
if (lockout) {
|
|
328
|
+
await this.#failResetOnThrow(userId, "lockout_reset_failed", () => lockout.reset(userId));
|
|
316
329
|
}
|
|
317
|
-
await this.#lockout?.reset(resolved.subject);
|
|
318
330
|
await this.#emit({
|
|
319
331
|
type: "password_reset.completed",
|
|
320
|
-
userId
|
|
332
|
+
userId,
|
|
333
|
+
...(sessionsRevoked === undefined ? {} : { sessionsRevoked }),
|
|
321
334
|
});
|
|
322
|
-
return { userId
|
|
335
|
+
return { userId };
|
|
336
|
+
}
|
|
337
|
+
async #failResetOnThrow(userId, reason, step) {
|
|
338
|
+
try {
|
|
339
|
+
await step();
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
await this.#emit({ type: "password_reset.failed", reason, userId });
|
|
343
|
+
throw error;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
async #clearLegacyAfterReset(userId) {
|
|
347
|
+
try {
|
|
348
|
+
await this.#users.clearLegacyCredential?.(userId);
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
console.error("[@udibo/oauth2] resetPassword legacy-credential clear failed:", error instanceof Error ? error.message : error);
|
|
352
|
+
}
|
|
323
353
|
}
|
|
324
354
|
/**
|
|
325
355
|
* Mint + deliver an email-verification link for a user. Typically called
|
|
@@ -565,7 +595,9 @@ export class IdentityService {
|
|
|
565
595
|
* the `signin_code.failed` event carries `locked`. Success consumes the code
|
|
566
596
|
* (single-use), resets the flow's throttle window, and carries the `userId`
|
|
567
597
|
* to sign in — session creation and your MFA gate stay your app's job, as
|
|
568
|
-
* does treating a since-deleted/disabled user as a failed sign-in.
|
|
598
|
+
* does treating a since-deleted/disabled user as a failed sign-in. A
|
|
599
|
+
* throttle reset that throws is logged and the result is still `success`,
|
|
600
|
+
* since the code is already spent.
|
|
569
601
|
*/
|
|
570
602
|
async verifySignInCode(input) {
|
|
571
603
|
const otp = this.#requireOtp("verifySignInCode");
|
|
@@ -593,7 +625,12 @@ export class IdentityService {
|
|
|
593
625
|
});
|
|
594
626
|
return { status: "invalid" };
|
|
595
627
|
}
|
|
596
|
-
|
|
628
|
+
try {
|
|
629
|
+
await this.#resetThrottle("signInCode", email);
|
|
630
|
+
}
|
|
631
|
+
catch (error) {
|
|
632
|
+
this.#logPersistFailure("sign-in code throttle reset", error);
|
|
633
|
+
}
|
|
597
634
|
await this.#emit({ type: "signin_code.verified", userId: user.id });
|
|
598
635
|
return { status: "success", userId: user.id };
|
|
599
636
|
}
|
|
@@ -719,14 +756,10 @@ export class IdentityService {
|
|
|
719
756
|
});
|
|
720
757
|
}
|
|
721
758
|
async #voidPasswordlessCredentials(tokens, subject, email) {
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
}
|
|
727
|
-
catch (error) {
|
|
728
|
-
console.error("[@udibo/oauth2] resetPassword could not void the outstanding " +
|
|
729
|
-
"passwordless credentials; they stay usable until they expire:", error instanceof Error ? error.message : error);
|
|
759
|
+
await logVoidFailure("sign-in links", () => tokens.invalidate(TokenPurpose.SignIn, subject));
|
|
760
|
+
const otp = this.#otp;
|
|
761
|
+
if (email && otp) {
|
|
762
|
+
await logVoidFailure("sign-in code", () => otp.invalidate(email, TokenPurpose.SignIn));
|
|
730
763
|
}
|
|
731
764
|
}
|
|
732
765
|
async #trapMintFailure(flow, mint) {
|
|
@@ -854,6 +887,15 @@ const DELIVERY_PURPOSES = {
|
|
|
854
887
|
sendEmailVerification: TokenPurpose.EmailVerification,
|
|
855
888
|
sendSignInLink: TokenPurpose.SignIn,
|
|
856
889
|
};
|
|
890
|
+
async function logVoidFailure(credential, invalidate) {
|
|
891
|
+
try {
|
|
892
|
+
await invalidate();
|
|
893
|
+
}
|
|
894
|
+
catch (error) {
|
|
895
|
+
console.error(`[@udibo/oauth2] resetPassword could not void the outstanding ` +
|
|
896
|
+
`${credential}, usable until expiry:`, error instanceof Error ? error.message : error);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
857
899
|
function emailFromData(data) {
|
|
858
900
|
const email = data?.email;
|
|
859
901
|
return typeof email === "string" ? email : undefined;
|
package/dist/models/token.d.ts
CHANGED
|
@@ -30,6 +30,14 @@ export interface Token<Client extends ClientInterface, User, Scope extends Abstr
|
|
|
30
30
|
claims?: Record<string, unknown>;
|
|
31
31
|
/** The authorization code used to generate this token, for replay detection per RFC 6819. */
|
|
32
32
|
code?: string;
|
|
33
|
+
/**
|
|
34
|
+
* The SHA-256 digest, as lowercase hex, of the PKCE `code_verifier` that
|
|
35
|
+
* redeemed {@linkcode Token.code}. The authorization code grant sets it on a
|
|
36
|
+
* code redeemed with PKCE. A token service persists it with the code, so
|
|
37
|
+
* `revokeCode` can tell a replay that presents that verifier from one that
|
|
38
|
+
* only names the client.
|
|
39
|
+
*/
|
|
40
|
+
codeVerifierHash?: string;
|
|
33
41
|
/**
|
|
34
42
|
* OIDC nonce carried transiently from the authorization code so the token
|
|
35
43
|
* endpoint can echo it in the id_token. Not persisted by token stores.
|
|
@@ -33,8 +33,10 @@ export interface CodeReuseEvent<Client extends ClientInterface> {
|
|
|
33
33
|
* The client that presented the code. It is the code's own client only when
|
|
34
34
|
* the token service's `revokeCode` honors its `client` argument. A public
|
|
35
35
|
* client, or any client the grant looks up by id because
|
|
36
|
-
* `requireClientAuthentication` is `false`, is named by `client_id` alone
|
|
37
|
-
*
|
|
36
|
+
* `requireClientAuthentication` is `false`, is named by `client_id` alone;
|
|
37
|
+
* its replay revokes, and so reaches this event, only when it presented the
|
|
38
|
+
* `code_verifier` that redeemed the code and `revokeCode` honors its
|
|
39
|
+
* `codeVerifierHash` argument.
|
|
38
40
|
*/
|
|
39
41
|
client: Client;
|
|
40
42
|
}
|
|
@@ -90,7 +92,9 @@ export interface AuthorizationCodeGrantOptions<Client extends ClientInterface, U
|
|
|
90
92
|
* token service's `revokeCode` revoked those tokens — record it as a
|
|
91
93
|
* security event. A `revokeCode` that honors its `client` argument revokes
|
|
92
94
|
* nothing, and so does not fire this, for a client other than the code's
|
|
93
|
-
* own; {@linkcode CodeReuseEvent.client} says who that excludes.
|
|
95
|
+
* own; {@linkcode CodeReuseEvent.client} says who that excludes. A client
|
|
96
|
+
* that did not authenticate with its secret fires it only by presenting the
|
|
97
|
+
* `code_verifier` that redeemed the code.
|
|
94
98
|
*
|
|
95
99
|
* Trigger point `code-reuse` (see `docs/trigger-points.md`).
|
|
96
100
|
* Fire-and-forget: it is awaited, but a throw is caught and logged, never
|
|
@@ -225,7 +229,14 @@ export declare class AuthorizationCodeGrant<Client extends ClientInterface, User
|
|
|
225
229
|
* then fails its checks, asks
|
|
226
230
|
* `tokenService.revokeCode` to revoke the tokens issued from it on behalf of
|
|
227
231
|
* the presenting client, fires `onCodeReuse` when it did, and fails with
|
|
228
|
-
* `invalid_grant` (RFC 6819 §4.4.1.1).
|
|
232
|
+
* `invalid_grant` (RFC 6819 §4.4.1.1). The replay must prove it could have
|
|
233
|
+
* redeemed the code: a client that authenticated with a `client_secret` in
|
|
234
|
+
* `request` asks with the client alone, and any other client asks with the
|
|
235
|
+
* SHA-256 hex digest of the `code_verifier` it presented, which `revokeCode`
|
|
236
|
+
* matches against {@linkcode Token.codeVerifierHash}. Without a verifier,
|
|
237
|
+
* such a client's replay revokes nothing. Pass the `client` that
|
|
238
|
+
* {@linkcode getAuthenticatedClient} resolved from this same `request`.
|
|
239
|
+
* A code redeemed with PKCE issues its token with `codeVerifierHash` set.
|
|
229
240
|
*
|
|
230
241
|
* @throws {InvalidRequestError} When `code` is missing, a required `code_verifier` is absent, or the verifier is malformed.
|
|
231
242
|
* @throws {InvalidGrantError} When the code is unknown, expired, already claimed, or its verifier or `redirect_uri` does not match.
|
|
@@ -188,7 +188,14 @@ export class AuthorizationCodeGrant extends AbstractGrant {
|
|
|
188
188
|
* then fails its checks, asks
|
|
189
189
|
* `tokenService.revokeCode` to revoke the tokens issued from it on behalf of
|
|
190
190
|
* the presenting client, fires `onCodeReuse` when it did, and fails with
|
|
191
|
-
* `invalid_grant` (RFC 6819 §4.4.1.1).
|
|
191
|
+
* `invalid_grant` (RFC 6819 §4.4.1.1). The replay must prove it could have
|
|
192
|
+
* redeemed the code: a client that authenticated with a `client_secret` in
|
|
193
|
+
* `request` asks with the client alone, and any other client asks with the
|
|
194
|
+
* SHA-256 hex digest of the `code_verifier` it presented, which `revokeCode`
|
|
195
|
+
* matches against {@linkcode Token.codeVerifierHash}. Without a verifier,
|
|
196
|
+
* such a client's replay revokes nothing. Pass the `client` that
|
|
197
|
+
* {@linkcode getAuthenticatedClient} resolved from this same `request`.
|
|
198
|
+
* A code redeemed with PKCE issues its token with `codeVerifierHash` set.
|
|
192
199
|
*
|
|
193
200
|
* @throws {InvalidRequestError} When `code` is missing, a required `code_verifier` is absent, or the verifier is malformed.
|
|
194
201
|
* @throws {InvalidGrantError} When the code is unknown, expired, already claimed, or its verifier or `redirect_uri` does not match.
|
|
@@ -202,9 +209,10 @@ export class AuthorizationCodeGrant extends AbstractGrant {
|
|
|
202
209
|
if (typeof code !== "string") {
|
|
203
210
|
throw new InvalidRequestError("code parameter required");
|
|
204
211
|
}
|
|
212
|
+
const replay = { code, client, request, body };
|
|
205
213
|
const authorizationCode = await authorizationCodeService.get(code);
|
|
206
214
|
if (!authorizationCode) {
|
|
207
|
-
throw await this.#refuseClaimedCode(tokenService,
|
|
215
|
+
throw await this.#refuseClaimedCode(tokenService, replay, "invalid code");
|
|
208
216
|
}
|
|
209
217
|
if (authorizationCode.expiresAt < new Date()) {
|
|
210
218
|
throw new InvalidGrantError("invalid code");
|
|
@@ -212,7 +220,7 @@ export class AuthorizationCodeGrant extends AbstractGrant {
|
|
|
212
220
|
const exchangeCode = tokenService.exchangeCode?.bind(tokenService);
|
|
213
221
|
if (!exchangeCode &&
|
|
214
222
|
!(await authorizationCodeService.revoke(authorizationCode))) {
|
|
215
|
-
throw await this.#refuseClaimedCode(tokenService,
|
|
223
|
+
throw await this.#refuseClaimedCode(tokenService, replay);
|
|
216
224
|
}
|
|
217
225
|
let token;
|
|
218
226
|
try {
|
|
@@ -221,11 +229,15 @@ export class AuthorizationCodeGrant extends AbstractGrant {
|
|
|
221
229
|
}
|
|
222
230
|
catch (error) {
|
|
223
231
|
if (exchangeCode) {
|
|
224
|
-
await this.#consumeFailedExchange(authorizationCodeService, tokenService, authorizationCode,
|
|
232
|
+
await this.#consumeFailedExchange(authorizationCodeService, tokenService, authorizationCode, replay);
|
|
225
233
|
}
|
|
226
234
|
throw error;
|
|
227
235
|
}
|
|
228
236
|
token.code = code;
|
|
237
|
+
const codeVerifier = body.get("code_verifier");
|
|
238
|
+
if (typeof codeVerifier === "string") {
|
|
239
|
+
token.codeVerifierHash = await sha256Hash(codeVerifier);
|
|
240
|
+
}
|
|
229
241
|
let saved;
|
|
230
242
|
if (exchangeCode) {
|
|
231
243
|
try {
|
|
@@ -233,7 +245,7 @@ export class AuthorizationCodeGrant extends AbstractGrant {
|
|
|
233
245
|
}
|
|
234
246
|
catch (error) {
|
|
235
247
|
if (isOAuth2Error(error)) {
|
|
236
|
-
await this.#consumeFailedExchange(authorizationCodeService, tokenService, authorizationCode,
|
|
248
|
+
await this.#consumeFailedExchange(authorizationCodeService, tokenService, authorizationCode, replay);
|
|
237
249
|
}
|
|
238
250
|
throw error;
|
|
239
251
|
}
|
|
@@ -242,7 +254,7 @@ export class AuthorizationCodeGrant extends AbstractGrant {
|
|
|
242
254
|
saved = await tokenService.save(token);
|
|
243
255
|
}
|
|
244
256
|
if (!saved) {
|
|
245
|
-
throw await this.#refuseClaimedCode(tokenService,
|
|
257
|
+
throw await this.#refuseClaimedCode(tokenService, replay);
|
|
246
258
|
}
|
|
247
259
|
if (authorizationCode.nonce)
|
|
248
260
|
saved.nonce = authorizationCode.nonce;
|
|
@@ -281,23 +293,38 @@ export class AuthorizationCodeGrant extends AbstractGrant {
|
|
|
281
293
|
throw new InvalidGrantError("did not expect redirect_uri parameter");
|
|
282
294
|
}
|
|
283
295
|
}
|
|
284
|
-
async #consumeFailedExchange(authorizationCodeService, tokenService, authorizationCode,
|
|
296
|
+
async #consumeFailedExchange(authorizationCodeService, tokenService, authorizationCode, replay) {
|
|
285
297
|
try {
|
|
286
298
|
if (!(await authorizationCodeService.revoke(authorizationCode))) {
|
|
287
|
-
await this.#refuseClaimedCode(tokenService,
|
|
299
|
+
await this.#refuseClaimedCode(tokenService, replay);
|
|
288
300
|
}
|
|
289
301
|
}
|
|
290
302
|
catch (error) {
|
|
291
303
|
console.error("[@udibo/oauth2] could not consume the code of a failed exchange:", error instanceof Error ? error.message : error);
|
|
292
304
|
}
|
|
293
305
|
}
|
|
294
|
-
async #refuseClaimedCode(tokenService, code, client, unrevokedMessage = "code already used") {
|
|
295
|
-
|
|
296
|
-
|
|
306
|
+
async #refuseClaimedCode(tokenService, { code, client, request, body }, unrevokedMessage = "code already used") {
|
|
307
|
+
const codeVerifier = body.get("code_verifier");
|
|
308
|
+
let revoked = false;
|
|
309
|
+
if (this.#authenticatedBySecret(request, body)) {
|
|
310
|
+
revoked = await tokenService.revokeCode(code, client);
|
|
311
|
+
}
|
|
312
|
+
else if (typeof codeVerifier === "string") {
|
|
313
|
+
revoked = await tokenService.revokeCode(code, client, await sha256Hash(codeVerifier));
|
|
297
314
|
}
|
|
315
|
+
if (!revoked)
|
|
316
|
+
return new InvalidGrantError(unrevokedMessage);
|
|
298
317
|
await this.#notifyCodeReuse({ codeHash: await sha256Hash(code), client });
|
|
299
318
|
return new InvalidGrantError("code already used");
|
|
300
319
|
}
|
|
320
|
+
#authenticatedBySecret(request, body) {
|
|
321
|
+
try {
|
|
322
|
+
return !!this.getClientCredentials(request, body).clientSecret;
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
301
328
|
async #notifyCodeReuse(event) {
|
|
302
329
|
if (!this.#onCodeReuse)
|
|
303
330
|
return;
|
|
@@ -173,13 +173,17 @@ export interface TokenServiceInterface<Client extends ClientInterface, User, Sco
|
|
|
173
173
|
* client the code's tokens were issued to; for any other client revoke
|
|
174
174
|
* nothing and resolve `false`. A different confidential client could never
|
|
175
175
|
* have redeemed the code, since that needs the code's own client's
|
|
176
|
-
* credentials, so its replay says nothing about theft.
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
176
|
+
* credentials, so its replay says nothing about theft. A store that ignores
|
|
177
|
+
* `client` revokes on any client's replay, which lets a client that sees a
|
|
178
|
+
* leaked, spent code end the session it started.
|
|
179
|
+
*
|
|
180
|
+
* `codeVerifierHash`, the SHA-256 hex digest of the `code_verifier` the
|
|
181
|
+
* replay presented, is passed for a client that did not authenticate with a
|
|
182
|
+
* secret. When it is given, also revoke only when it equals the
|
|
183
|
+
* {@linkcode Token.codeVerifierHash} persisted with the code's tokens;
|
|
184
|
+
* tokens saved without one never match. Omitted, the client check alone
|
|
185
|
+
* applies. A store that ignores it lets anyone holding a public client's
|
|
186
|
+
* spent code end the session it started.
|
|
183
187
|
*
|
|
184
188
|
* With {@linkcode exchangeCode}, make the two linearizable: a call that
|
|
185
189
|
* begins once `exchangeCode` has claimed the code must find and revoke the
|
|
@@ -188,7 +192,7 @@ export interface TokenServiceInterface<Client extends ClientInterface, User, Sco
|
|
|
188
192
|
*
|
|
189
193
|
* @see https://datatracker.ietf.org/doc/html/rfc6819#section-4.4.1.1
|
|
190
194
|
*/
|
|
191
|
-
revokeCode(code: string, client?: Client): Promise<boolean>;
|
|
195
|
+
revokeCode(code: string, client?: Client, codeVerifierHash?: string): Promise<boolean>;
|
|
192
196
|
/**
|
|
193
197
|
* Exchanges an authorization code in one atomic step: claims
|
|
194
198
|
* `authorizationCode` as `AuthorizationCodeServiceInterface.revoke`
|
|
@@ -207,8 +211,9 @@ export interface TokenServiceInterface<Client extends ClientInterface, User, Sco
|
|
|
207
211
|
*
|
|
208
212
|
* After a successful call the code service's `get` must resolve `undefined`
|
|
209
213
|
* for the code, as after `revoke`. A failed insert must leave the code
|
|
210
|
-
* claimable. Store the token so `revokeCode` finds it by the code,
|
|
211
|
-
* the two linearizable, as
|
|
214
|
+
* claimable. Store the token so `revokeCode` finds it by the code, with its
|
|
215
|
+
* {@linkcode Token.codeVerifierHash}, and make the two linearizable, as
|
|
216
|
+
* `revokeCode` requires.
|
|
212
217
|
* `runCodeExchangeContractTests` checks it under concurrent callers, with
|
|
213
218
|
* the store's barrier wired between its claim and its insert.
|
|
214
219
|
*/
|
|
@@ -372,8 +377,9 @@ export declare abstract class AbstractTokenService<Client extends ClientInterfac
|
|
|
372
377
|
/**
|
|
373
378
|
* Revokes every token issued from the given authorization code, returning
|
|
374
379
|
* true if any were revoked (signaling code replay per RFC 6819 §4.4.1.1).
|
|
375
|
-
* Revoke nothing when `client` is not the client the tokens were issued to
|
|
376
|
-
*
|
|
380
|
+
* Revoke nothing when `client` is not the client the tokens were issued to,
|
|
381
|
+
* or when `codeVerifierHash` is given and is not the one they were saved
|
|
382
|
+
* with ({@linkcode TokenServiceInterface.revokeCode} explains why).
|
|
377
383
|
*/
|
|
378
|
-
abstract revokeCode(code: string, client?: Client): Promise<boolean>;
|
|
384
|
+
abstract revokeCode(code: string, client?: Client, codeVerifierHash?: string): Promise<boolean>;
|
|
379
385
|
}
|
|
@@ -87,6 +87,15 @@ export interface CodeExchangeContractOptions<C extends ClientInterface, U extend
|
|
|
87
87
|
* longer to reach the lock they wait on.
|
|
88
88
|
*/
|
|
89
89
|
blockedAfterMs?: number;
|
|
90
|
+
/**
|
|
91
|
+
* Set to `false` when the service's `revokeCode` deliberately ignores its
|
|
92
|
+
* `codeVerifierHash` argument, as `runTokenServiceContractTests` allows. The
|
|
93
|
+
* suite still checks that a token `exchangeCode` saved revokes for its own
|
|
94
|
+
* verifier, and registers an ignored case naming what is not covered — a
|
|
95
|
+
* replay that names a public client, with any verifier, revokes the token
|
|
96
|
+
* `exchangeCode` saved. Defaults to `true`.
|
|
97
|
+
*/
|
|
98
|
+
revokeCodeChecksVerifier?: boolean;
|
|
90
99
|
/** Overrides the name passed to the outer `describe` block. */
|
|
91
100
|
describeName?: string;
|
|
92
101
|
}
|
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
import { assert, beforeEach, describe, expect, it } from "vitest";
|
|
25
25
|
import { InvalidGrantError } from "../../errors.js";
|
|
26
26
|
import { AuthorizationCodeGrant, } from "../../server/grants/authorization-code.js";
|
|
27
|
+
import { sha256Hash } from "../../server/utils/hash.js";
|
|
28
|
+
import { generateCodeChallenge, generateCodeVerifier, } from "../../utils/pkce.js";
|
|
27
29
|
import { race } from "./_race.js";
|
|
28
30
|
const defaultMakeUser = (seq) => ({ id: `u${seq}`, username: `user${seq}` });
|
|
29
31
|
const defaultMakeClient = (seq) => ({
|
|
@@ -57,6 +59,7 @@ export function runCodeExchangeContractTests(options) {
|
|
|
57
59
|
const makeClient = options.makeClient ?? defaultMakeClient;
|
|
58
60
|
const pausable = options.exchangeAwaitsBarrier ?? true;
|
|
59
61
|
const blockedAfterMs = options.blockedAfterMs ?? 100;
|
|
62
|
+
const expectCodeVerifierCheck = options.revokeCodeChecksVerifier ?? true;
|
|
60
63
|
describe(options.describeName ?? "TokenServiceInterface.exchangeCode contract", () => {
|
|
61
64
|
let services;
|
|
62
65
|
let codeService;
|
|
@@ -64,6 +67,7 @@ export function runCodeExchangeContractTests(options) {
|
|
|
64
67
|
let user;
|
|
65
68
|
let client;
|
|
66
69
|
let armed;
|
|
70
|
+
let verifiers;
|
|
67
71
|
const barriers = {
|
|
68
72
|
async afterClaim() {
|
|
69
73
|
const hold = armed;
|
|
@@ -76,6 +80,7 @@ export function runCodeExchangeContractTests(options) {
|
|
|
76
80
|
};
|
|
77
81
|
beforeEach(async () => {
|
|
78
82
|
armed = undefined;
|
|
83
|
+
verifiers = new Map();
|
|
79
84
|
services = await options.makeServices(barriers);
|
|
80
85
|
codeService = services.authorizationCodeService;
|
|
81
86
|
tokenService = services.tokenService;
|
|
@@ -88,24 +93,37 @@ export function runCodeExchangeContractTests(options) {
|
|
|
88
93
|
assert(typeof tokenService.exchangeCode === "function", "runCodeExchangeContractTests needs a token service that implements exchangeCode");
|
|
89
94
|
return tokenService.exchangeCode(authorizationCode, token);
|
|
90
95
|
}
|
|
96
|
+
function verifierOf(authorizationCode) {
|
|
97
|
+
const verifier = verifiers.get(authorizationCode.code);
|
|
98
|
+
assert(verifier, "the suite saved no verifier for this code");
|
|
99
|
+
return verifier;
|
|
100
|
+
}
|
|
91
101
|
async function savedCode() {
|
|
102
|
+
const verifier = generateCodeVerifier();
|
|
92
103
|
const authorizationCode = {
|
|
93
104
|
code: await codeService.generateCode(client, user),
|
|
94
105
|
expiresAt: await codeService.expiresAt(client, user),
|
|
95
106
|
client,
|
|
96
107
|
user,
|
|
108
|
+
challenge: await generateCodeChallenge(verifier),
|
|
109
|
+
challengeMethod: "S256",
|
|
97
110
|
};
|
|
111
|
+
verifiers.set(authorizationCode.code, verifier);
|
|
98
112
|
await codeService.save(authorizationCode);
|
|
99
113
|
return authorizationCode;
|
|
100
114
|
}
|
|
101
115
|
async function tokenFor(authorizationCode) {
|
|
102
|
-
|
|
116
|
+
const token = {
|
|
103
117
|
accessToken: await tokenService.generateAccessToken(client, user),
|
|
104
118
|
accessTokenExpiresAt: new Date(Date.now() + 60_000),
|
|
105
119
|
client,
|
|
106
120
|
user,
|
|
107
121
|
code: authorizationCode.code,
|
|
108
122
|
};
|
|
123
|
+
const verifier = verifiers.get(authorizationCode.code);
|
|
124
|
+
if (verifier)
|
|
125
|
+
token.codeVerifierHash = await sha256Hash(verifier);
|
|
126
|
+
return token;
|
|
109
127
|
}
|
|
110
128
|
async function live(tokens) {
|
|
111
129
|
const found = [];
|
|
@@ -154,6 +172,8 @@ export function runCodeExchangeContractTests(options) {
|
|
|
154
172
|
const body = new FormData();
|
|
155
173
|
body.set("grant_type", "authorization_code");
|
|
156
174
|
body.set("code", authorizationCode.code);
|
|
175
|
+
body.set("client_id", client.id.toString());
|
|
176
|
+
body.set("code_verifier", verifierOf(authorizationCode));
|
|
157
177
|
return grant.token(new Request("https://as.test/token", { method: "POST" }), client, body);
|
|
158
178
|
}
|
|
159
179
|
it("implements exchangeCode", () => {
|
|
@@ -170,6 +190,30 @@ export function runCodeExchangeContractTests(options) {
|
|
|
170
190
|
expect(await tokenService.revokeCode(authorizationCode.code, client), "revokeCode must find the token exchangeCode saved by its code").toBe(true);
|
|
171
191
|
expect(await live([token])).toStrictEqual([]);
|
|
172
192
|
});
|
|
193
|
+
it("revokes the token it saved for the codeVerifierHash it was saved with", async () => {
|
|
194
|
+
const authorizationCode = await savedCode();
|
|
195
|
+
const token = await tokenFor(authorizationCode);
|
|
196
|
+
await exchangeCode(authorizationCode, token);
|
|
197
|
+
expect(await tokenService.revokeCode(authorizationCode.code, client, token.codeVerifierHash), "exchangeCode must persist codeVerifierHash with the token, or a " +
|
|
198
|
+
"replay with the redeeming verifier cannot revoke it").toBe(true);
|
|
199
|
+
expect(await live([token])).toStrictEqual([]);
|
|
200
|
+
});
|
|
201
|
+
if (expectCodeVerifierCheck) {
|
|
202
|
+
it("keeps the token's codeVerifierHash, so revokeCode refuses another verifier and revokes for its own", async () => {
|
|
203
|
+
const authorizationCode = await savedCode();
|
|
204
|
+
const token = await tokenFor(authorizationCode);
|
|
205
|
+
await exchangeCode(authorizationCode, token);
|
|
206
|
+
expect(await tokenService.revokeCode(authorizationCode.code, client, await sha256Hash(generateCodeVerifier())), "a replay that does not present the redeeming verifier must " +
|
|
207
|
+
"revoke nothing").toBe(false);
|
|
208
|
+
expect(await live([token])).toStrictEqual([token.accessToken]);
|
|
209
|
+
expect(await tokenService.revokeCode(authorizationCode.code, client, token.codeVerifierHash), "exchangeCode must persist codeVerifierHash with the token, or a " +
|
|
210
|
+
"replay with the redeeming verifier cannot revoke it").toBe(true);
|
|
211
|
+
expect(await live([token])).toStrictEqual([]);
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
it.skip("revokeCode ignores the presented verifier — a replay that names a public client, with any verifier, revokes the token exchangeCode saved (revokeCodeChecksVerifier: false)", () => { });
|
|
216
|
+
}
|
|
173
217
|
it("refuses a code that was already claimed or never stored, persisting nothing", async () => {
|
|
174
218
|
const claimed = await savedCode();
|
|
175
219
|
await codeService.revoke(claimed.code);
|
|
@@ -30,6 +30,18 @@ export interface IdentityUserStoreContractFixture<User extends IdentityUser> {
|
|
|
30
30
|
isEmailVerified?(userId: string): Promise<boolean>;
|
|
31
31
|
/** Seed an imported hash. Required for legacyCredentials. */
|
|
32
32
|
setLegacyCredential?(userId: string, hash: string): Promise<void>;
|
|
33
|
+
/** Start a live session for the user and return its id. Required for resetCredential. */
|
|
34
|
+
startSession?(userId: string): Promise<string>;
|
|
35
|
+
/** Whether the application would still accept the session. Required for resetCredential. */
|
|
36
|
+
isSessionLive?(sessionId: string): Promise<boolean>;
|
|
37
|
+
/**
|
|
38
|
+
* Make the next resetCredential call throw at `step`, after the other
|
|
39
|
+
* writes have had their chance to run inside the transaction. Required for
|
|
40
|
+
* resetCredential: the suite calls it once for each step (`"legacy"` only
|
|
41
|
+
* with legacyCredentials) to prove that no write survives another failing,
|
|
42
|
+
* whichever order your transaction runs them in.
|
|
43
|
+
*/
|
|
44
|
+
failNextReset?(step: "credential" | "legacy" | "sessions"): Promise<void> | void;
|
|
33
45
|
/** Release resources and remove this test's records after all calls finish. */
|
|
34
46
|
dispose?(): Promise<void> | void;
|
|
35
47
|
}
|
|
@@ -45,6 +57,12 @@ export interface IdentityUserStoreContractOptions<User extends IdentityUser> {
|
|
|
45
57
|
emailVerification?: boolean;
|
|
46
58
|
/** Require imported credential lookup and clearing, plus its setup helper. */
|
|
47
59
|
legacyCredentials?: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Require the transactional password-reset capability and its session and
|
|
62
|
+
* fault-injection helpers. With legacyCredentials, also checks that the
|
|
63
|
+
* reset drops the imported hash.
|
|
64
|
+
*/
|
|
65
|
+
resetCredential?: boolean;
|
|
48
66
|
}
|
|
49
67
|
/**
|
|
50
68
|
* Register the IdentityUserStore suite in a Vitest test file. Credential fixtures
|
|
@@ -137,6 +137,60 @@ export function runIdentityUserStoreContractTests(options) {
|
|
|
137
137
|
expect(await store.getCredential(other.id)).toStrictEqual(ORIGINAL);
|
|
138
138
|
});
|
|
139
139
|
}
|
|
140
|
+
if (options.resetCredential) {
|
|
141
|
+
async function resetSetup() {
|
|
142
|
+
assert.exists(store.resetCredential, "resetCredential capability is required");
|
|
143
|
+
assert.exists(fixture.startSession, "startSession setup helper is required");
|
|
144
|
+
assert.exists(fixture.isSessionLive, "isSessionLive setup helper is required");
|
|
145
|
+
const user = await create();
|
|
146
|
+
const other = await create(2);
|
|
147
|
+
if (options.legacyCredentials) {
|
|
148
|
+
assert.exists(fixture.setLegacyCredential);
|
|
149
|
+
await fixture.setLegacyCredential(user.id, "imported-hash-one");
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
user,
|
|
153
|
+
other,
|
|
154
|
+
sessions: [
|
|
155
|
+
await fixture.startSession(user.id),
|
|
156
|
+
await fixture.startSession(user.id),
|
|
157
|
+
],
|
|
158
|
+
otherSession: await fixture.startSession(other.id),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
async function liveness(ids) {
|
|
162
|
+
return await Promise.all(ids.map((id) => fixture.isSessionLive(id)));
|
|
163
|
+
}
|
|
164
|
+
it("resets the credential and revokes every session of the user, and only that user", async () => {
|
|
165
|
+
const { user, other, sessions, otherSession } = await resetSetup();
|
|
166
|
+
expect(await store.resetCredential(user.id, structuredClone(REPLACEMENT)), "resetCredential must report the sessions it revoked").toStrictEqual(2);
|
|
167
|
+
expect(await store.getCredential(user.id)).toStrictEqual(REPLACEMENT);
|
|
168
|
+
expect(await liveness(sessions), "resetCredential revokes every session of the user").toStrictEqual([false, false]);
|
|
169
|
+
if (options.legacyCredentials) {
|
|
170
|
+
expect(await store.getLegacyCredential(user.id), "resetCredential drops the imported hash").toBe(null);
|
|
171
|
+
}
|
|
172
|
+
expect(await store.getCredential(other.id)).toStrictEqual(ORIGINAL);
|
|
173
|
+
expect(await fixture.isSessionLive(otherSession), "resetCredential must not revoke another user's sessions").toBe(true);
|
|
174
|
+
expect(await store.resetCredential(user.id, structuredClone(REPLACEMENT))).toStrictEqual(0);
|
|
175
|
+
});
|
|
176
|
+
const steps = options.legacyCredentials
|
|
177
|
+
? ["credential", "legacy", "sessions"]
|
|
178
|
+
: ["credential", "sessions"];
|
|
179
|
+
for (const step of steps) {
|
|
180
|
+
it(`changes nothing when the reset fails at the ${step} step`, async () => {
|
|
181
|
+
const { user, sessions } = await resetSetup();
|
|
182
|
+
assert.exists(fixture.failNextReset, "failNextReset fault-injection helper is required");
|
|
183
|
+
await fixture.failNextReset(step);
|
|
184
|
+
const outcome = await store.resetCredential(user.id, structuredClone(REPLACEMENT)).then(() => "resolved", () => "rejected");
|
|
185
|
+
expect(outcome, "resetCredential must reject when failNextReset injected a failure").toStrictEqual("rejected");
|
|
186
|
+
expect(await store.getCredential(user.id), "a failed reset must leave the credential unchanged").toStrictEqual(ORIGINAL);
|
|
187
|
+
expect(await liveness(sessions), "a failed reset must leave the sessions live").toStrictEqual([true, true]);
|
|
188
|
+
if (options.legacyCredentials) {
|
|
189
|
+
expect(await store.getLegacyCredential(user.id), "a failed reset must leave the imported hash in place").toStrictEqual("imported-hash-one");
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
140
194
|
if (options.emailVerification) {
|
|
141
195
|
it("verifies only the issued-for email and is idempotent", async () => {
|
|
142
196
|
const user = await create();
|
|
@@ -86,6 +86,15 @@ export interface TokenServiceContractOptions<C extends ClientInterface, U extend
|
|
|
86
86
|
* started — instead of passing in silence. Defaults to `true`.
|
|
87
87
|
*/
|
|
88
88
|
revokeCodeChecksClient?: boolean;
|
|
89
|
+
/**
|
|
90
|
+
* Set to `false` when the service's `revokeCode` deliberately ignores its
|
|
91
|
+
* `codeVerifierHash` argument. The suite then registers an ignored case
|
|
92
|
+
* naming what is not covered — a replay that names a public client by
|
|
93
|
+
* `client_id`, with any verifier, revokes the tokens issued from its spent
|
|
94
|
+
* code, so anyone who holds a leaked code can end the session it started —
|
|
95
|
+
* instead of passing in silence. Defaults to `true`.
|
|
96
|
+
*/
|
|
97
|
+
revokeCodeChecksVerifier?: boolean;
|
|
89
98
|
/** Overrides the name passed to the outer `describe` block. */
|
|
90
99
|
describeName?: string;
|
|
91
100
|
}
|
|
@@ -27,6 +27,7 @@ const defaultMakeClient = (seq) => ({
|
|
|
27
27
|
});
|
|
28
28
|
const defaultMakeScope = (value) => new BasicScope(value);
|
|
29
29
|
const defaultMakeFamilyId = () => crypto.randomUUID();
|
|
30
|
+
const randomVerifierHash = () => Array.from(crypto.getRandomValues(new Uint8Array(32)), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
30
31
|
/** Runs the {@link TokenServiceInterface} contract suite. */
|
|
31
32
|
export function runTokenServiceContractTests(options) {
|
|
32
33
|
const makeUser = options.makeUser ?? defaultMakeUser;
|
|
@@ -36,6 +37,7 @@ export function runTokenServiceContractTests(options) {
|
|
|
36
37
|
const makeFamilyId = options.makeFamilyId ?? defaultMakeFamilyId;
|
|
37
38
|
const expectReuseDetection = options.refreshTokenReuseDetection ?? true;
|
|
38
39
|
const expectCodeClientCheck = options.revokeCodeChecksClient ?? true;
|
|
40
|
+
const expectCodeVerifierCheck = options.revokeCodeChecksVerifier ?? true;
|
|
39
41
|
describe(options.describeName ?? "TokenServiceInterface contract", () => {
|
|
40
42
|
let userService;
|
|
41
43
|
let clientService;
|
|
@@ -292,6 +294,72 @@ export function runTokenServiceContractTests(options) {
|
|
|
292
294
|
else {
|
|
293
295
|
it.skip("revokeCode ignores the presenting client — any client that presents a spent code revokes its tokens (revokeCodeChecksClient: false)", () => { });
|
|
294
296
|
}
|
|
297
|
+
if (expectCodeVerifierCheck) {
|
|
298
|
+
async function issueBoundCode(code, codeVerifierHash) {
|
|
299
|
+
const accessToken = await tokenService.generateAccessToken(client, user);
|
|
300
|
+
await tokenService.save({
|
|
301
|
+
accessToken,
|
|
302
|
+
code,
|
|
303
|
+
codeVerifierHash,
|
|
304
|
+
client,
|
|
305
|
+
user,
|
|
306
|
+
});
|
|
307
|
+
return accessToken;
|
|
308
|
+
}
|
|
309
|
+
it("revokes nothing for a codeVerifierHash other than the one the code's tokens were saved with", async () => {
|
|
310
|
+
const accessToken = await issueBoundCode("bound-code", randomVerifierHash());
|
|
311
|
+
expect(await tokenService.revokeCode("bound-code", client, randomVerifierHash()), "a replay without the verifier that redeemed the code proves " +
|
|
312
|
+
"nothing, so it must not revoke what the code issued").toBe(false);
|
|
313
|
+
expect(await tokenService.getToken(accessToken)).not.toBe(undefined);
|
|
314
|
+
});
|
|
315
|
+
it("revokes for the codeVerifierHash the code's tokens were saved with, after another was refused", async () => {
|
|
316
|
+
const codeVerifierHash = randomVerifierHash();
|
|
317
|
+
const accessToken = await issueBoundCode("bound-code", codeVerifierHash);
|
|
318
|
+
await tokenService.revokeCode("bound-code", client, randomVerifierHash());
|
|
319
|
+
expect(await tokenService.revokeCode("bound-code", client, codeVerifierHash)).toBe(true);
|
|
320
|
+
expect(await tokenService.getToken(accessToken)).toBe(undefined);
|
|
321
|
+
});
|
|
322
|
+
if (expectCodeClientCheck) {
|
|
323
|
+
it("revokes nothing for another client that presents the codeVerifierHash the code's tokens were saved with", async () => {
|
|
324
|
+
const other = makeClient(2);
|
|
325
|
+
await options.addClient(clientService, other);
|
|
326
|
+
const codeVerifierHash = randomVerifierHash();
|
|
327
|
+
const accessToken = await issueBoundCode("bound-code", codeVerifierHash);
|
|
328
|
+
expect(await tokenService.revokeCode("bound-code", other, codeVerifierHash), "a matching verifier does not lift the client check").toBe(false);
|
|
329
|
+
expect(await tokenService.getToken(accessToken)).not.toBe(undefined);
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
it("revokes nothing for a codeVerifierHash when the code's tokens were saved without one", async () => {
|
|
333
|
+
const accessToken = await issueBoundCode("unbound-code");
|
|
334
|
+
expect(await tokenService.revokeCode("unbound-code", client, randomVerifierHash()), "a code redeemed without PKCE binds no verifier, so no verifier " +
|
|
335
|
+
"a replay presents can match it").toBe(false);
|
|
336
|
+
expect(await tokenService.getToken(accessToken)).not.toBe(undefined);
|
|
337
|
+
});
|
|
338
|
+
it("revokes a code saved with a codeVerifierHash when none is passed, for a client that authenticated", async () => {
|
|
339
|
+
const accessToken = await issueBoundCode("bound-code", randomVerifierHash());
|
|
340
|
+
expect(await tokenService.revokeCode("bound-code", client), "the grant omits codeVerifierHash for a client that " +
|
|
341
|
+
"authenticated with its secret, where the client check suffices").toBe(true);
|
|
342
|
+
expect(await tokenService.getToken(accessToken)).toBe(undefined);
|
|
343
|
+
});
|
|
344
|
+
it("revokes nothing in a rotated family for a codeVerifierHash other than the one the code's tokens were saved with", async () => {
|
|
345
|
+
const family = makeFamilyId();
|
|
346
|
+
const issued = {
|
|
347
|
+
...(await familyMember(family)),
|
|
348
|
+
code: "rotated-bound-code",
|
|
349
|
+
codeVerifierHash: randomVerifierHash(),
|
|
350
|
+
};
|
|
351
|
+
await tokenService.save(issued);
|
|
352
|
+
const rotated = await familyMember(family);
|
|
353
|
+
await rotateTo(issued, rotated);
|
|
354
|
+
expect(await tokenService.revokeCode("rotated-bound-code", client, randomVerifierHash())).toBe(false);
|
|
355
|
+
expect(await isLive(rotated), "the verifier check comes before any family is revoked — a " +
|
|
356
|
+
"replay without the redeeming verifier must not end the " +
|
|
357
|
+
"session rotated from the code").toBe(true);
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
else {
|
|
361
|
+
it.skip("revokeCode ignores the presented verifier — a replay that names a public client, with any verifier, revokes its spent code's tokens (revokeCodeChecksVerifier: false)", () => { });
|
|
362
|
+
}
|
|
295
363
|
});
|
|
296
364
|
if (expectReuseDetection) {
|
|
297
365
|
describe("refresh-token rotation family", () => {
|
|
@@ -298,9 +298,10 @@ export declare class MemoryTokenService<C extends ClientInterface, U extends Mem
|
|
|
298
298
|
* {@linkcode revokeFamily} does, so their rotated successors end with them.
|
|
299
299
|
* Resolves `true` when tokens were issued from the code.
|
|
300
300
|
* Removes nothing and resolves `false` when `client` is given and is not the
|
|
301
|
-
* client the code's tokens were issued to
|
|
301
|
+
* client the code's tokens were issued to, or when `codeVerifierHash` is
|
|
302
|
+
* given and is not the one they were saved with.
|
|
302
303
|
*/
|
|
303
|
-
revokeCode(code: string, client?: C): Promise<boolean>;
|
|
304
|
+
revokeCode(code: string, client?: C, codeVerifierHash?: string): Promise<boolean>;
|
|
304
305
|
}
|
|
305
306
|
/** Options for {@link MemoryAuthorizationCodeService}. */
|
|
306
307
|
export interface MemoryAuthorizationCodeServiceOptions<C extends ClientInterface, U extends MemoryUserShape> {
|
package/dist/testing/services.js
CHANGED
|
@@ -354,6 +354,9 @@ export class MemoryTokenService {
|
|
|
354
354
|
};
|
|
355
355
|
if (token.code)
|
|
356
356
|
stored.codeHash = await sha256Hash(token.code);
|
|
357
|
+
if (token.codeVerifierHash) {
|
|
358
|
+
stored.codeVerifierHash = token.codeVerifierHash;
|
|
359
|
+
}
|
|
357
360
|
if ("refreshToken" in token) {
|
|
358
361
|
const rt = token;
|
|
359
362
|
stored.refreshTokenHash = await sha256Hash(rt.refreshToken);
|
|
@@ -369,6 +372,7 @@ export class MemoryTokenService {
|
|
|
369
372
|
if (!bucket) {
|
|
370
373
|
bucket = {
|
|
371
374
|
clientId: stored.clientId,
|
|
375
|
+
codeVerifierHash: stored.codeVerifierHash,
|
|
372
376
|
accessTokenHashes: new Set(),
|
|
373
377
|
familyIds: new Set(),
|
|
374
378
|
};
|
|
@@ -516,9 +520,10 @@ export class MemoryTokenService {
|
|
|
516
520
|
* {@linkcode revokeFamily} does, so their rotated successors end with them.
|
|
517
521
|
* Resolves `true` when tokens were issued from the code.
|
|
518
522
|
* Removes nothing and resolves `false` when `client` is given and is not the
|
|
519
|
-
* client the code's tokens were issued to
|
|
523
|
+
* client the code's tokens were issued to, or when `codeVerifierHash` is
|
|
524
|
+
* given and is not the one they were saved with.
|
|
520
525
|
*/
|
|
521
|
-
async revokeCode(code, client) {
|
|
526
|
+
async revokeCode(code, client, codeVerifierHash) {
|
|
522
527
|
const codeHash = await sha256Hash(code);
|
|
523
528
|
const bucket = this.#tokensByCodeHash.get(codeHash);
|
|
524
529
|
if (!bucket || bucket.accessTokenHashes.size === 0)
|
|
@@ -526,6 +531,10 @@ export class MemoryTokenService {
|
|
|
526
531
|
if (client && client.id.toString() !== bucket.clientId.toString()) {
|
|
527
532
|
return false;
|
|
528
533
|
}
|
|
534
|
+
if (codeVerifierHash !== undefined &&
|
|
535
|
+
codeVerifierHash !== bucket.codeVerifierHash) {
|
|
536
|
+
return false;
|
|
537
|
+
}
|
|
529
538
|
for (const familyId of bucket.familyIds)
|
|
530
539
|
this.#revokeFamily(familyId);
|
|
531
540
|
for (const accessTokenHash of bucket.accessTokenHashes) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@udibo/oauth2",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.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",
|