@udibo/oauth2 0.24.0 → 0.26.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.
@@ -134,6 +134,11 @@ export const ENDPOINT_PATHS = Object.fromEntries(ENDPOINT_ENTRIES.map(([key, { p
134
134
  * ```
135
135
  */
136
136
  export const ENDPOINT_METHODS = Object.fromEntries(ENDPOINT_ENTRIES.map(([key, { methods }]) => [key, methods]));
137
+ function authorizationResponseRedirect(target) {
138
+ if (target.issuer)
139
+ target.url.searchParams.set("iss", target.issuer);
140
+ return Response.redirect(target.url.toString(), 302);
141
+ }
137
142
  function isAuthorizationCodeGrant(grant) {
138
143
  return grant instanceof AuthorizationCodeGrant;
139
144
  }
@@ -742,6 +747,11 @@ export class AuthorizationServer extends ResourceServer {
742
747
  * responses from this endpoint, since there is no verified place to send the
743
748
  * user back to.
744
749
  *
750
+ * Every redirect to the client, the code and the error alike, carries `iss`
751
+ * set to the request's resolved issuer (RFC 9207), replacing any `iss` the
752
+ * registered `redirect_uri` itself carries. A request that resolves no
753
+ * `issuer` sends none.
754
+ *
745
755
  * Requires a grant extending {@linkcode AuthorizationCodeGrant} registered
746
756
  * under `authorization_code`; without one the endpoint answers 500
747
757
  * `server_error` (or rethrows, with `throwOnError`).
@@ -757,16 +767,17 @@ export class AuthorizationServer extends ResourceServer {
757
767
  return await this.#authorize(request, target, authenticateUser, handleConsent);
758
768
  }
759
769
  catch (error) {
760
- return this.#authorizeErrorResponse(error, target.url);
770
+ return this.#authorizeErrorResponse(error, target);
761
771
  }
762
772
  }
763
773
  async #authorize(request, target, authenticateUser, handleConsent) {
764
774
  const params = this.parseAuthorizeParameters(request);
765
775
  const grant = this.#authorizationCodeGrant();
766
- const { services } = await this.authorizationContext(request);
776
+ const { services, issuer } = await this.authorizationContext(request);
767
777
  const client = await this.#resolveAuthorizeClient(grant, params.clientId, services);
768
778
  const redirectUrl = this.#resolveRedirectTarget(client, params.redirectUri);
769
779
  target.url = redirectUrl;
780
+ target.issuer = issuer;
770
781
  if (!params.state) {
771
782
  throw new InvalidRequestError("state required");
772
783
  }
@@ -807,9 +818,10 @@ export class AuthorizationServer extends ResourceServer {
807
818
  authenticationContext,
808
819
  }, request);
809
820
  redirectUrl.searchParams.set("code", authorizationCode.code);
810
- return Response.redirect(redirectUrl.toString(), 302);
821
+ return authorizationResponseRedirect({ url: redirectUrl, issuer });
811
822
  }
812
- #authorizeErrorResponse(error, redirectUrl) {
823
+ #authorizeErrorResponse(error, target) {
824
+ const redirectUrl = target.url;
813
825
  if (!redirectUrl || !(error instanceof Error) || !isOAuth2Error(error)) {
814
826
  return this.#handleEndpointError(error);
815
827
  }
@@ -824,7 +836,10 @@ export class AuthorizationServer extends ResourceServer {
824
836
  if (errorUri) {
825
837
  searchParams.set("error_uri", errorUri);
826
838
  }
827
- return Response.redirect(redirectUrl.toString(), 302);
839
+ return authorizationResponseRedirect({
840
+ url: redirectUrl,
841
+ issuer: target.issuer,
842
+ });
828
843
  }
829
844
  #authorizationCodeGrant() {
830
845
  const grant = this.grants[AUTHORIZATION_CODE_GRANT_TYPE];
@@ -1136,7 +1151,9 @@ export class AuthorizationServer extends ResourceServer {
1136
1151
  * authorization code grant's own `challengeMethods`, so a server that
1137
1152
  * registers an extra method (or drops `S256`) advertises what it will
1138
1153
  * actually accept. It is omitted entirely when no authorization code grant
1139
- * is registered, since PKCE has nothing to apply to.
1154
+ * is registered, since PKCE has nothing to apply to. The same condition
1155
+ * governs `authorization_response_iss_parameter_supported: true`
1156
+ * (RFC 9207 §3): the authorization endpoint's redirects carry `iss`.
1140
1157
  *
1141
1158
  * `token_endpoint_auth_methods_supported` always includes `none`, the
1142
1159
  * RFC 7591 method for a client with no secret. The token endpoint accepts it
@@ -1178,6 +1195,9 @@ export class AuthorizationServer extends ResourceServer {
1178
1195
  metadata.code_challenge_methods_supported = challengeMethods;
1179
1196
  }
1180
1197
  metadata.response_types_supported = hasAuthCodeGrant ? ["code"] : [];
1198
+ if (hasAuthCodeGrant) {
1199
+ metadata.authorization_response_iss_parameter_supported = true;
1200
+ }
1181
1201
  const scopesSupported = context.scopesSupported ?? this.scopesSupported;
1182
1202
  if (scopesSupported) {
1183
1203
  metadata.scopes_supported = scopesSupported;
@@ -86,10 +86,14 @@ export declare class RefreshTokenGrant<Client extends ClientInterface, User, S e
86
86
  * token (and rotates the refresh token when the token service supports it)
87
87
  * from a valid, unexpired refresh token.
88
88
  *
89
- * Rotation revokes the exchanged token through the token service's
90
- * `revokeRotated` when implemented, falling back to `revoke` — so a service
91
- * whose `revoke` ends the owning session can keep this bookkeeping step
92
- * from signing the person out.
89
+ * Rotation claims the exchanged token and saves its successor in one call
90
+ * to the token service's `rotate` when implemented. Otherwise it takes two
91
+ * calls: it revokes the exchanged token through `revokeRotated` when
92
+ * implemented, falling back to `revoke` — so a service whose `revoke` ends
93
+ * the owning session can keep this bookkeeping step from signing the
94
+ * person out — and then saves the successor. A refresh that presents the
95
+ * exchanged token between those two calls is treated as reuse
96
+ * ({@linkcode TokenServiceInterface.rotate} describes the cost).
93
97
  *
94
98
  * Rotation renews the refresh token's sliding lifetime, bounded by the
95
99
  * family's absolute cap when the token service implements
@@ -32,10 +32,14 @@ export class RefreshTokenGrant extends AbstractGrant {
32
32
  * token (and rotates the refresh token when the token service supports it)
33
33
  * from a valid, unexpired refresh token.
34
34
  *
35
- * Rotation revokes the exchanged token through the token service's
36
- * `revokeRotated` when implemented, falling back to `revoke` — so a service
37
- * whose `revoke` ends the owning session can keep this bookkeeping step
38
- * from signing the person out.
35
+ * Rotation claims the exchanged token and saves its successor in one call
36
+ * to the token service's `rotate` when implemented. Otherwise it takes two
37
+ * calls: it revokes the exchanged token through `revokeRotated` when
38
+ * implemented, falling back to `revoke` — so a service whose `revoke` ends
39
+ * the owning session can keep this bookkeeping step from signing the
40
+ * person out — and then saves the successor. A refresh that presents the
41
+ * exchanged token between those two calls is treated as reuse
42
+ * ({@linkcode TokenServiceInterface.rotate} describes the cost).
39
43
  *
40
44
  * Rotation renews the refresh token's sliding lifetime, bounded by the
41
45
  * family's absolute cap when the token service implements
@@ -122,6 +126,13 @@ export class RefreshTokenGrant extends AbstractGrant {
122
126
  if (familyExpiresAt) {
123
127
  this.capAtFamilyExpiry(nextRefreshToken, familyExpiresAt);
124
128
  }
129
+ if (tokenService.rotate) {
130
+ const rotated = await tokenService.rotate(currentToken, nextRefreshToken);
131
+ if (!rotated) {
132
+ throw new InvalidGrantError("refresh_token was already consumed");
133
+ }
134
+ return rotated;
135
+ }
125
136
  const claimed = tokenService.revokeRotated
126
137
  ? await tokenService.revokeRotated(currentToken)
127
138
  : await tokenService.revoke(currentToken);
@@ -129,6 +129,32 @@ export interface TokenServiceInterface<Client extends ClientInterface, User, Sco
129
129
  * it, so a sign-out racing a refresh still ends the successor.
130
130
  */
131
131
  revokeRotated?(token: RefreshToken<Client, User, Scope>): Promise<boolean>;
132
+ /**
133
+ * Rotates a refresh token in one atomic step: claims `current` and persists
134
+ * `next`, its successor in the same family, or does neither. Resolves the
135
+ * saved successor, or `undefined` with nothing persisted when `current` was
136
+ * already claimed or revoked, or its family has been revoked. Concurrent
137
+ * calls for one `current` produce exactly one successor.
138
+ *
139
+ * When implemented, the refresh grant calls it instead of
140
+ * {@linkcode revokeRotated} (or {@linkcode revoke}) followed by
141
+ * {@linkcode save}. Without it, the grant claims and saves in two calls,
142
+ * and a request that presents `current` between them finds it revoked with
143
+ * no live successor: reuse detection revokes the family and fires
144
+ * `onTokenReuse`, so a benign concurrent refresh signs the person out, and a
145
+ * store whose `revokeFamily` does not keep the family revoked leaves the
146
+ * successor live.
147
+ *
148
+ * Treat the claim as a rotation, never an end of grant: stamp the
149
+ * rotated-out record with {@linkcode RefreshToken.rotatedAt} exactly as
150
+ * {@linkcode revokeRotated} does, keep it findable through
151
+ * {@linkcode getRevokedRefreshToken}, and revoke its access token as
152
+ * `revokeRotated` would. Make it linearizable with
153
+ * {@linkcode revokeFamily}, as that method requires.
154
+ * `runTokenRotationContractTests` checks it under concurrent callers, with
155
+ * the store's barrier wired between its claim and its insert.
156
+ */
157
+ rotate?(current: RefreshToken<Client, User, Scope>, next: RefreshToken<Client, User, Scope>): Promise<RefreshToken<Client, User, Scope> | undefined>;
132
158
  /**
133
159
  * Revokes all tokens issued with the given authorization code.
134
160
  * Returns true if any tokens were revoked (indicating code replay).
@@ -164,9 +190,17 @@ export interface TokenServiceInterface<Client extends ClientInterface, User, Sco
164
190
  * `/revoke` ends the family without firing `onTokenReuse`, so any audit
165
191
  * trail for that case has to come from this method.
166
192
  *
167
- * Keep the family revoked: a successor that a concurrent rotation saves
168
- * after this call must be stored already revoked, as
169
- * `MemoryTokenService` does.
193
+ * Without {@linkcode rotate}, keep the family revoked: a successor that a
194
+ * concurrent rotation saves after this call must be stored already revoked,
195
+ * as `MemoryTokenService` does. A store that implements `rotate` meets this
196
+ * through the linearizability below, since the refresh grant then never
197
+ * saves a rotation's successor separately.
198
+ *
199
+ * With {@linkcode rotate}, make the two linearizable: once this resolves,
200
+ * the family has no live member and no later `rotate` of a member
201
+ * succeeds, and a `rotate` that committed before this began has its
202
+ * successor revoked. A store whose `rotate` can be paused between its claim
203
+ * and its insert meets this by taking one per-family lock in both methods.
170
204
  */
171
205
  revokeFamily?(familyId: string): Promise<boolean>;
172
206
  /**
@@ -24,6 +24,12 @@
24
24
  * also have separate suites. They use app-owned fixture helpers to verify
25
25
  * conditional credential writes and immediate, user-scoped session revocation.
26
26
  *
27
+ * `TokenServiceInterface.rotate` has a runner of its own for concurrent
28
+ * schedules: two rotations of one refresh token, a family revocation racing a
29
+ * rotation, and a replay racing a refresh. A store that awaits between its
30
+ * claim and its insert wires `TokenRotationBarriers` into `rotate`, so the
31
+ * suite can hold a rotation there.
32
+ *
27
33
  * The service runners take a factory plus seed helpers because `add` /
28
34
  * `register` methods aren't part of the public service interfaces — the
29
35
  * consumer tells the runner how to populate test data.
@@ -33,6 +39,7 @@
33
39
  export { runUserServiceContractTests, type UserServiceContractOptions, } from "./user.ts";
34
40
  export { type ClientServiceContractOptions, runClientServiceContractTests, } from "./client.ts";
35
41
  export { runTokenServiceContractTests, type TokenServiceContractOptions, } from "./token.ts";
42
+ export { runTokenRotationContractTests, type TokenRotationBarriers, type TokenRotationContractOptions, type TokenRotationContractServices, } from "./token-rotation.ts";
36
43
  export { runTokenReaderContractTests, type TokenReaderContractFixture, type TokenReaderContractOptions, } from "./token-reader.ts";
37
44
  export { type AuthorizationCodeServiceContractOptions, runAuthorizationCodeServiceContractTests, } from "./authorization-code.ts";
38
45
  export { type DeviceAuthorizationServiceContractOptions, runDeviceAuthorizationServiceContractTests, } from "./device-authorization.ts";
@@ -24,6 +24,12 @@
24
24
  * also have separate suites. They use app-owned fixture helpers to verify
25
25
  * conditional credential writes and immediate, user-scoped session revocation.
26
26
  *
27
+ * `TokenServiceInterface.rotate` has a runner of its own for concurrent
28
+ * schedules: two rotations of one refresh token, a family revocation racing a
29
+ * rotation, and a replay racing a refresh. A store that awaits between its
30
+ * claim and its insert wires `TokenRotationBarriers` into `rotate`, so the
31
+ * suite can hold a rotation there.
32
+ *
27
33
  * The service runners take a factory plus seed helpers because `add` /
28
34
  * `register` methods aren't part of the public service interfaces — the
29
35
  * consumer tells the runner how to populate test data.
@@ -33,6 +39,7 @@
33
39
  export { runUserServiceContractTests, } from "./user.js";
34
40
  export { runClientServiceContractTests, } from "./client.js";
35
41
  export { runTokenServiceContractTests, } from "./token.js";
42
+ export { runTokenRotationContractTests, } from "./token-rotation.js";
36
43
  export { runTokenReaderContractTests, } from "./token-reader.js";
37
44
  export { runAuthorizationCodeServiceContractTests, } from "./authorization-code.js";
38
45
  export { runDeviceAuthorizationServiceContractTests, } from "./device-authorization.js";
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Contract test suite for {@link TokenServiceInterface.rotate} and its
3
+ * linearizability with {@link TokenServiceInterface.revokeFamily}.
4
+ *
5
+ * `runTokenServiceContractTests` checks what one caller sees. This suite
6
+ * checks what concurrent callers see: two rotations of one refresh token, a
7
+ * family revocation racing a rotation, a replay racing a refresh through the
8
+ * refresh grant, a rotation in an unrelated family, and a rotation whose
9
+ * insert fails. Each case asserts the store's end state, the live tokens of
10
+ * the family, not only the value each call resolved.
11
+ *
12
+ * The store under test awaits {@link TokenRotationBarriers.afterClaim} inside
13
+ * `rotate`, between its claim and its insert, so the suite can hold a rotation
14
+ * there and race the other call against it; a store that never reaches the
15
+ * barrier fails every held case. Only a store whose `rotate` never awaits
16
+ * between its claim and its insert, such as `MemoryTokenService`, opts out with
17
+ * `rotateAwaitsBarrier: false`; the suite then runs those schedules as
18
+ * overlapping calls.
19
+ *
20
+ * @module
21
+ */
22
+ import type { ClientInterface } from "../../models/client.ts";
23
+ import type { AbstractScope, BasicScope } from "../../models/scope.ts";
24
+ import type { ClientServiceInterface } from "../../server/services/client.ts";
25
+ import type { TokenServiceInterface } from "../../server/services/token.ts";
26
+ import type { UserServiceInterface } from "../../server/services/user.ts";
27
+ import type { MemoryUserShape } from "../services.ts";
28
+ /**
29
+ * The point inside a store's {@linkcode TokenServiceInterface.rotate} at which
30
+ * {@link runTokenRotationContractTests} holds a rotation. Wire it into a test
31
+ * build of the store.
32
+ */
33
+ export interface TokenRotationBarriers {
34
+ /**
35
+ * Await this inside `rotate` after claiming `current` and before persisting
36
+ * `next`, still inside the atomic step: within the transaction and holding
37
+ * the family's lock. When it rejects, fail the rotation as a failed insert
38
+ * would: leave `current` live, persist nothing, and reject or resolve
39
+ * `undefined`.
40
+ */
41
+ afterClaim(): Promise<void>;
42
+ }
43
+ /** Services built for each case of {@link runTokenRotationContractTests}. */
44
+ export interface TokenRotationContractServices<C extends ClientInterface, U extends MemoryUserShape, S extends AbstractScope = BasicScope> {
45
+ /** The user service the token service hydrates users from. */
46
+ userService: UserServiceInterface<U>;
47
+ /** The client service the token service hydrates clients from. */
48
+ clientService: ClientServiceInterface<C, U>;
49
+ /** The token service under test. It must implement `rotate`. */
50
+ tokenService: TokenServiceInterface<C, U, S>;
51
+ }
52
+ /** Options for {@link runTokenRotationContractTests}. */
53
+ export interface TokenRotationContractOptions<C extends ClientInterface, U extends MemoryUserShape, S extends AbstractScope = BasicScope> {
54
+ /**
55
+ * Returns fresh services for each case, with `rotate` awaiting
56
+ * `barriers.afterClaim()` between its claim and its insert. A store that
57
+ * sets `rotateAwaitsBarrier: false` ignores `barriers`.
58
+ */
59
+ makeServices(barriers: TokenRotationBarriers): Promise<TokenRotationContractServices<C, U, S>> | TokenRotationContractServices<C, U, S>;
60
+ /** Persists a user with the given password into the user service under test. */
61
+ addUser(service: UserServiceInterface<U>, user: U, password: string): Promise<void>;
62
+ /** Persists a client into the client service under test. */
63
+ addClient(service: ClientServiceInterface<C, U>, client: C): Promise<void>;
64
+ /** Builds a distinct user fixture for the given sequence number. Defaults to a minimal `{ id, username }` shape. */
65
+ makeUser?(seq: number): U;
66
+ /** Builds a distinct client fixture for the given sequence number. Defaults to a client granting `authorization_code` and `refresh_token`. */
67
+ makeClient?(seq: number): C;
68
+ /**
69
+ * Whether `rotate` awaits `barriers.afterClaim()`. Defaults to `true`: the
70
+ * suite holds a rotation between its claim and its insert while it runs a
71
+ * family revocation, a second rotation, a replayed refresh, or a rotation in
72
+ * another family, and fails the insert there. Set `false` only when
73
+ * `rotate` never awaits between its claim and its insert, as in
74
+ * `MemoryTokenService`; the suite then runs those schedules as overlapping
75
+ * calls, which cannot reach a gap inside `rotate`.
76
+ */
77
+ rotateAwaitsBarrier?: boolean;
78
+ /**
79
+ * Milliseconds the suite lets a call run against a held rotation before it
80
+ * releases the rotation; a call that has not settled by then is taken to
81
+ * be waiting on it. Defaults to 100. Raise it for a store whose calls take
82
+ * longer to reach the lock they wait on.
83
+ */
84
+ blockedAfterMs?: number;
85
+ /** Overrides the name passed to the outer `describe` block. */
86
+ describeName?: string;
87
+ }
88
+ /**
89
+ * Runs the rotation contract suite: concurrent schedules of
90
+ * {@linkcode TokenServiceInterface.rotate} against itself, against
91
+ * {@linkcode TokenServiceInterface.revokeFamily}, and through the refresh
92
+ * grant. The service must implement `rotate`, `revokeFamily` and
93
+ * `getRevokedRefreshToken`.
94
+ */
95
+ export declare function runTokenRotationContractTests<C extends ClientInterface, U extends MemoryUserShape, S extends AbstractScope = BasicScope>(options: TokenRotationContractOptions<C, U, S>): void;