@udibo/oauth2 0.34.0 → 0.36.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/dist/adapters/hono/bff/auth-request-store.d.ts +4 -1
- package/dist/adapters/hono/bff/auth-request-store.js +4 -1
- package/dist/cli/idp/server.js +2 -1
- package/dist/server/authorization-server.d.ts +32 -2
- package/dist/server/authorization-server.js +21 -2
- package/dist/server/grants/authorization-code.d.ts +56 -5
- package/dist/server/grants/authorization-code.js +97 -21
- package/dist/server/services/token.d.ts +58 -9
- package/dist/testing/contract/code-exchange.d.ts +100 -0
- package/dist/testing/contract/code-exchange.js +309 -0
- package/dist/testing/contract/mod.d.ts +6 -0
- package/dist/testing/contract/mod.js +6 -0
- package/dist/testing/contract/token-rotation.js +13 -0
- package/dist/testing/contract/token.d.ts +8 -0
- package/dist/testing/contract/token.js +121 -19
- package/dist/testing/server.js +3 -2
- package/dist/testing/services.d.ts +26 -6
- package/dist/testing/services.js +55 -10
- package/package.json +1 -1
|
@@ -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
|
package/dist/cli/idp/server.js
CHANGED
|
@@ -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
|
-
*
|
|
173
|
-
* `redirect_uri`
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
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
|
-
*
|
|
168
|
-
* `redirect_uri`
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
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
|
|
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
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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,62 @@ 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
|
-
*
|
|
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
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
+
* After the client check below passes, revoke every live member of the
|
|
166
|
+
* rotation families of those tokens too, as {@linkcode revokeFamily} does,
|
|
167
|
+
* keeping them revoked against a rotation in flight. A refresh token rotated
|
|
168
|
+
* from the code's token carries no `code`, so revoking only the tokens saved
|
|
169
|
+
* with the code leaves the stolen code's session refreshing once it has
|
|
170
|
+
* rotated.
|
|
171
|
+
*
|
|
172
|
+
* `client` is the client that presented the code. Revoke only when it is the
|
|
173
|
+
* client the code's tokens were issued to; for any other client revoke
|
|
174
|
+
* nothing and resolve `false`. A different confidential client could never
|
|
175
|
+
* have redeemed the code, since that needs the code's own client's
|
|
176
|
+
* credentials, so its replay says nothing about theft. The gate names a
|
|
177
|
+
* public client, or any client when the grant's
|
|
178
|
+
* `requireClientAuthentication` is `false`, by `client_id` alone, so anyone
|
|
179
|
+
* holding such a client's spent code can still present it as that client
|
|
180
|
+
* and revoke. A store that ignores `client` revokes on any client's replay,
|
|
181
|
+
* which lets a client that sees a leaked, spent code end the session it
|
|
182
|
+
* started.
|
|
183
|
+
*
|
|
184
|
+
* With {@linkcode exchangeCode}, make the two linearizable: a call that
|
|
185
|
+
* begins once `exchangeCode` has claimed the code must find and revoke the
|
|
186
|
+
* token it saved. Committing the claim and the insert together, as one
|
|
187
|
+
* database transaction does, meets this.
|
|
165
188
|
*
|
|
166
189
|
* @see https://datatracker.ietf.org/doc/html/rfc6819#section-4.4.1.1
|
|
167
190
|
*/
|
|
168
|
-
revokeCode(code: string): Promise<boolean>;
|
|
191
|
+
revokeCode(code: string, client?: Client): Promise<boolean>;
|
|
192
|
+
/**
|
|
193
|
+
* Exchanges an authorization code in one atomic step: claims
|
|
194
|
+
* `authorizationCode` as `AuthorizationCodeServiceInterface.revoke`
|
|
195
|
+
* would, and persists `token`, the token issued from it, or does neither.
|
|
196
|
+
* Resolves the saved token, or `undefined` with nothing persisted when the
|
|
197
|
+
* code was already claimed or never stored. Concurrent calls for one code
|
|
198
|
+
* persist exactly one token. `token.code` is already set to the code.
|
|
199
|
+
*
|
|
200
|
+
* When implemented, the authorization code grant calls it instead of
|
|
201
|
+
* claiming the code through the authorization code service and saving the
|
|
202
|
+
* token with {@linkcode save}. Without it, the grant claims and saves in two
|
|
203
|
+
* calls, and a replay that arrives between them finds the code claimed but
|
|
204
|
+
* no token to revoke: the replay is refused, and the token the first
|
|
205
|
+
* exchange saves next stays live. With it, a replay finds the token
|
|
206
|
+
* {@linkcode revokeCode} can revoke.
|
|
207
|
+
*
|
|
208
|
+
* After a successful call the code service's `get` must resolve `undefined`
|
|
209
|
+
* 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, and make
|
|
211
|
+
* the two linearizable, as `revokeCode` requires.
|
|
212
|
+
* `runCodeExchangeContractTests` checks it under concurrent callers, with
|
|
213
|
+
* the store's barrier wired between its claim and its insert.
|
|
214
|
+
*/
|
|
215
|
+
exchangeCode?(authorizationCode: AuthorizationCode<Client, User, Scope>, token: Token<Client, User, Scope>): Promise<Token<Client, User, Scope> | undefined>;
|
|
169
216
|
/**
|
|
170
217
|
* Looks up a refresh token that has already been revoked/rotated out. The
|
|
171
218
|
* refresh grant's reuse detection reads it, and so does the revocation
|
|
@@ -188,8 +235,8 @@ export interface TokenServiceInterface<Client extends ClientInterface, User, Sco
|
|
|
188
235
|
* given a token whose tombstone carries {@linkcode RefreshToken.rotatedAt}.
|
|
189
236
|
* Returns true if anything was revoked.
|
|
190
237
|
*
|
|
191
|
-
* `/revoke` ends the family without firing `onTokenReuse
|
|
192
|
-
*
|
|
238
|
+
* `/revoke` ends the family without firing `onTokenReuse`; it reports the
|
|
239
|
+
* revocation to `AuthorizationServerOptions.onRotatedOutRevocation` instead.
|
|
193
240
|
*
|
|
194
241
|
* Without {@linkcode rotate}, keep the family revoked: a successor that a
|
|
195
242
|
* concurrent rotation saves after this call must be stored already revoked,
|
|
@@ -325,6 +372,8 @@ export declare abstract class AbstractTokenService<Client extends ClientInterfac
|
|
|
325
372
|
/**
|
|
326
373
|
* Revokes every token issued from the given authorization code, returning
|
|
327
374
|
* 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
|
+
* ({@linkcode TokenServiceInterface.revokeCode} explains why).
|
|
328
377
|
*/
|
|
329
|
-
abstract revokeCode(code: string): Promise<boolean>;
|
|
378
|
+
abstract revokeCode(code: string, client?: Client): Promise<boolean>;
|
|
330
379
|
}
|
|
@@ -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;
|