@udibo/oauth2 0.23.0 → 0.25.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.
@@ -32,12 +32,12 @@ import type { AuthFormClassNames } from "./types.ts";
32
32
  *
33
33
  * @example Theme a custom field to match the form around it
34
34
  * ```tsx
35
- * function TenantField(props: { fieldProps: AuthFieldProps }) {
35
+ * function InviteCodeField(props: { fieldProps: AuthFieldProps }) {
36
36
  * const classNames = useAuthFormClassNames();
37
37
  * return (
38
38
  * <div className={classNames.field}>
39
39
  * <label className={classNames.label} htmlFor={props.fieldProps.id}>
40
- * Tenant
40
+ * Invite code
41
41
  * </label>
42
42
  * <input {...props.fieldProps} className={classNames.input} />
43
43
  * </div>
@@ -49,12 +49,12 @@ function slotKey(classNames) {
49
49
  *
50
50
  * @example Theme a custom field to match the form around it
51
51
  * ```tsx
52
- * function TenantField(props: { fieldProps: AuthFieldProps }) {
52
+ * function InviteCodeField(props: { fieldProps: AuthFieldProps }) {
53
53
  * const classNames = useAuthFormClassNames();
54
54
  * return (
55
55
  * <div className={classNames.field}>
56
56
  * <label className={classNames.label} htmlFor={props.fieldProps.id}>
57
- * Tenant
57
+ * Invite code
58
58
  * </label>
59
59
  * <input {...props.fieldProps} className={classNames.input} />
60
60
  * </div>
@@ -9,12 +9,13 @@
9
9
  * - Server metadata (RFC 8414)
10
10
  * - Device authorization (RFC 8628)
11
11
  *
12
- * **Configuration.** A single instance serves every tenant/origin. The only
13
- * configuration seam is `resolve(request)`, which returns the services the
14
- * server's own endpoints need (`{ clientService, tokenService }`) plus the
15
- * issuer/endpoints. Each grant resolves its own services independently. For a
16
- * single-tenant server, `resolve` is a constant function (create the services
17
- * once, outside it, and return them every time).
12
+ * **Configuration.** The only configuration seam is `resolve(request)`, which
13
+ * returns the per-request configuration: the services the server's own
14
+ * endpoints need (`{ clientService, tokenService }`) plus the issuer and
15
+ * endpoint URLs. Because it runs per request, one server can serve the origins
16
+ * or applications a business operates. Each grant resolves its own services
17
+ * independently. For a single deployment, `resolve` is a constant function
18
+ * (create the services once, outside it, and return them every time).
18
19
  *
19
20
  * @see https://datatracker.ietf.org/doc/html/rfc6749
20
21
  * @module
@@ -161,9 +162,11 @@ export interface AuthorizationServerContext<Client extends ClientInterface, User
161
162
  */
162
163
  export interface AuthorizationServerOptions<Client extends ClientInterface, User, S extends AbstractScope> extends Omit<ResourceServerOptions<Client, User, S>, "resolve"> {
163
164
  /**
164
- * Resolves the per-request context — the server's own services plus the
165
- * issuer/endpoints. This is the only services seam: for a single-tenant
166
- * server it is a constant function returning the same context every time.
165
+ * Resolves the per-request configuration — the server's own services plus
166
+ * the issuer and endpoint URLs — so one server can serve the origins or
167
+ * applications a business operates. This is the only services seam: for a
168
+ * single deployment it is a constant function returning the same context
169
+ * every time.
167
170
  */
168
171
  resolve: (request: Request) => AuthorizationServerContext<Client, User, S> | Promise<AuthorizationServerContext<Client, User, S>>;
169
172
  /** Grants the server supports, keyed by `grant_type` (each key must equal `grant.grantType`). */
@@ -406,8 +409,8 @@ export declare const ENDPOINT_METHODS: Readonly<Record<EndpointKey, readonly str
406
409
  * Framework-agnostic: each `handle*Request` method takes a web `Request` and
407
410
  * returns a `Response`, so an adapter routes its endpoints to them. Extends
408
411
  * {@linkcode ResourceServer}, so the same instance also validates bearer
409
- * tokens. A single instance serves every tenant/origin via the `resolve`
410
- * callback in {@linkcode AuthorizationServerOptions}.
412
+ * tokens. Its issuer, endpoint URLs and services come per request from the
413
+ * `resolve` callback in {@linkcode AuthorizationServerOptions}.
411
414
  *
412
415
  * The handlers throw {@linkcode HttpError} subclasses on failure (e.g.
413
416
  * {@linkcode InvalidRequestError}, {@linkcode InvalidClientError},
@@ -9,12 +9,13 @@
9
9
  * - Server metadata (RFC 8414)
10
10
  * - Device authorization (RFC 8628)
11
11
  *
12
- * **Configuration.** A single instance serves every tenant/origin. The only
13
- * configuration seam is `resolve(request)`, which returns the services the
14
- * server's own endpoints need (`{ clientService, tokenService }`) plus the
15
- * issuer/endpoints. Each grant resolves its own services independently. For a
16
- * single-tenant server, `resolve` is a constant function (create the services
17
- * once, outside it, and return them every time).
12
+ * **Configuration.** The only configuration seam is `resolve(request)`, which
13
+ * returns the per-request configuration: the services the server's own
14
+ * endpoints need (`{ clientService, tokenService }`) plus the issuer and
15
+ * endpoint URLs. Because it runs per request, one server can serve the origins
16
+ * or applications a business operates. Each grant resolves its own services
17
+ * independently. For a single deployment, `resolve` is a constant function
18
+ * (create the services once, outside it, and return them every time).
18
19
  *
19
20
  * @see https://datatracker.ietf.org/doc/html/rfc6749
20
21
  * @module
@@ -154,8 +155,8 @@ function isDeviceAuthorizationGrant(grant) {
154
155
  * Framework-agnostic: each `handle*Request` method takes a web `Request` and
155
156
  * returns a `Response`, so an adapter routes its endpoints to them. Extends
156
157
  * {@linkcode ResourceServer}, so the same instance also validates bearer
157
- * tokens. A single instance serves every tenant/origin via the `resolve`
158
- * callback in {@linkcode AuthorizationServerOptions}.
158
+ * tokens. Its issuer, endpoint URLs and services come per request from the
159
+ * `resolve` callback in {@linkcode AuthorizationServerOptions}.
159
160
  *
160
161
  * The handlers throw {@linkcode HttpError} subclasses on failure (e.g.
161
162
  * {@linkcode InvalidRequestError}, {@linkcode InvalidClientError},
@@ -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);
@@ -22,17 +22,18 @@ export interface ResourceServerServices<Client extends ClientInterface, User, S
22
22
  }
23
23
  /** Per-request context resolved by a {@link ResourceServer}. */
24
24
  export interface ResourceServerContext<Client extends ClientInterface, User, S extends AbstractScope> {
25
- /** Tenant-scoped services to use for this request. */
25
+ /** The services to use for this request. */
26
26
  services: ResourceServerServices<Client, User, S>;
27
27
  }
28
28
  /** Options for configuring the resource server. */
29
29
  export interface ResourceServerOptions<Client extends ClientInterface, User, S extends AbstractScope> {
30
30
  /**
31
- * Resolves the per-request context — the (e.g. tenant-scoped) {@link
32
- * ResourceServerServices} for THIS request — so a single instance serves
33
- * every tenant/origin. This is the only services seam: for a single-tenant
34
- * server it is a constant function returning the same services every time
35
- * (create the services once, outside `resolve`, and return them here).
31
+ * Resolves the per-request configuration — the {@link
32
+ * ResourceServerServices} for THIS request — so one server can serve the
33
+ * origins or applications a business operates. This is the only services
34
+ * seam: for a single deployment it is a constant function returning the
35
+ * same services every time (create the services once, outside `resolve`,
36
+ * and return them here).
36
37
  */
37
38
  resolve: (request: Request) => ResourceServerContext<Client, User, S> | Promise<ResourceServerContext<Client, User, S>>;
38
39
  /** Scope constructor used to parse and compare scopes. Defaults to {@linkcode BasicScope}. */
@@ -133,10 +134,9 @@ export interface AuthenticatedContext<Client extends ClientInterface, User, S ex
133
134
  * authentication. Framework-agnostic: it works on web `Request`/`Response`, so
134
135
  * an adapter (Hono, etc.) wires {@linkcode authenticate} into its middleware.
135
136
  *
136
- * A single instance serves every tenant/origin; per-request services come from
137
- * the `resolve` callback in {@linkcode ResourceServerOptions}. For a
138
- * single-tenant server, `resolve` is a constant function returning the same
139
- * services every time.
137
+ * Per-request services come from the `resolve` callback in
138
+ * {@linkcode ResourceServerOptions}. For a single deployment, `resolve` is a
139
+ * constant function returning the same services every time.
140
140
  *
141
141
  * {@linkcode authenticate} throws {@linkcode HttpError} subclasses on failure
142
142
  * ({@linkcode AccessDeniedError} when no token is sent,
@@ -31,10 +31,9 @@ const CHALLENGE_ERROR_CODES = new Set([
31
31
  * authentication. Framework-agnostic: it works on web `Request`/`Response`, so
32
32
  * an adapter (Hono, etc.) wires {@linkcode authenticate} into its middleware.
33
33
  *
34
- * A single instance serves every tenant/origin; per-request services come from
35
- * the `resolve` callback in {@linkcode ResourceServerOptions}. For a
36
- * single-tenant server, `resolve` is a constant function returning the same
37
- * services every time.
34
+ * Per-request services come from the `resolve` callback in
35
+ * {@linkcode ResourceServerOptions}. For a single deployment, `resolve` is a
36
+ * constant function returning the same services every time.
38
37
  *
39
38
  * {@linkcode authenticate} throws {@linkcode HttpError} subclasses on failure
40
39
  * ({@linkcode AccessDeniedError} when no token is sent,
@@ -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;