@julr/sesame 0.5.1 → 0.6.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.
Files changed (73) hide show
  1. package/LICENSE.md +1 -1
  2. package/README.md +405 -62
  3. package/build/authorize_controller-BiycO4be.js +251 -0
  4. package/build/chunk-DF48asd8.js +9 -0
  5. package/build/{client_info_controller-BucHGx4u.js → client_info_controller-AcOG8lWu.js} +11 -3
  6. package/build/commands/sesame_client.d.ts +20 -0
  7. package/build/commands/sesame_key.d.ts +12 -0
  8. package/build/commands/sesame_purge.d.ts +0 -2
  9. package/build/commands/sesame_purge.js +15 -3
  10. package/build/configure-DkDkIlt8.js +27 -0
  11. package/build/configure.js +2 -24
  12. package/build/consent_controller-Dsdhv6-f.js +108 -0
  13. package/build/id_token_service-CpTzOUDe.js +54 -0
  14. package/build/index.d.ts +1 -1
  15. package/build/index.js +30 -10
  16. package/build/{introspect_controller-6bRt9sZt.js → introspect_controller-DvOp9scr.js} +21 -7
  17. package/build/issue_authorization_code-B9ERu1uO.js +40 -0
  18. package/build/jwks_controller-keo4kBZc.js +26 -0
  19. package/build/{main-EbeMS5S9.js → main-DGBJhq3E.js} +34 -4
  20. package/build/{metadata_controller-DeaMRnUr.js → metadata_controller-BVsTo0Gp.js} +83 -6
  21. package/build/{oauth_access_token-bsoM5KeU.js → oauth_access_token-Cz_5gNBx.js} +12 -1
  22. package/build/oauth_client-BSanvSql.js +63 -0
  23. package/build/oauth_error-C7UhDb2q.js +189 -0
  24. package/build/providers/sesame_provider.js +14 -3
  25. package/build/{register_controller-sIJ1rxdM.js → register_controller-gbq7p8a5.js} +46 -7
  26. package/build/{revoke_controller-D6isoQCi.js → revoke_controller-z_ghrEB7.js} +21 -8
  27. package/build/services/main.js +7 -3
  28. package/build/sesame_manager-B1Jgq1v2.js +6 -0
  29. package/build/sesame_manager-DYUSZ0NC.js +693 -0
  30. package/build/src/actions/authorize.d.ts +46 -0
  31. package/build/src/actions/exchange_authorization_code.d.ts +34 -0
  32. package/build/src/actions/exchange_client_credentials.d.ts +28 -0
  33. package/build/src/actions/exchange_refresh_token.d.ts +59 -0
  34. package/build/src/actions/issue_authorization_code.d.ts +26 -0
  35. package/build/src/controllers/authorize_controller.d.ts +13 -12
  36. package/build/src/controllers/consent_controller.d.ts +5 -0
  37. package/build/src/controllers/jwks_controller.d.ts +14 -0
  38. package/build/src/controllers/metadata_controller.d.ts +8 -1
  39. package/build/src/controllers/token_controller.d.ts +8 -5
  40. package/build/src/controllers/userinfo_controller.d.ts +14 -0
  41. package/build/src/guard/main.js +5 -5
  42. package/build/src/middleware/any_scope_middleware.js +11 -1
  43. package/build/src/middleware/scope_middleware.js +11 -1
  44. package/build/src/models/oauth_authorization_code.d.ts +1 -0
  45. package/build/src/models/oauth_pending_authorization_request.d.ts +1 -0
  46. package/build/src/oauth_error.d.ts +1 -1
  47. package/build/src/routes.d.ts +3 -1
  48. package/build/src/services/id_token_service.d.ts +30 -0
  49. package/build/src/services/key_service.d.ts +20 -0
  50. package/build/src/sesame_manager.d.ts +54 -3
  51. package/build/src/types.d.ts +112 -0
  52. package/build/stubs/main.ts +5 -0
  53. package/build/stubs/migrations/create_oauth_authorization_codes_table.stub +1 -0
  54. package/build/stubs/migrations/create_oauth_pending_authorization_requests_table.stub +1 -0
  55. package/build/stubs/migrations/create_oauth_refresh_tokens_table.stub +1 -1
  56. package/build/token_controller-DyI7oy-U.js +481 -0
  57. package/build/token_service-DwnfAR9F.js +59 -0
  58. package/build/userinfo_controller-RLk8cN_o.js +40 -0
  59. package/build/vite.config.d.ts +2 -0
  60. package/package.json +26 -41
  61. package/build/authorize_controller-YUfAy-R2.js +0 -138
  62. package/build/client_service-WTNMqWzY.js +0 -65
  63. package/build/consent_controller-Dprwd1ed.js +0 -85
  64. package/build/decorate-BKZEjPRg.js +0 -15
  65. package/build/oauth_client-BIoY5jBR.js +0 -24
  66. package/build/oauth_error-CnJ3L8tf.js +0 -94
  67. package/build/sesame_manager-Bu4MHqZV.js +0 -4
  68. package/build/sesame_manager-DwDZy5Vy.js +0 -167
  69. package/build/src/grants/authorization_code_grant.d.ts +0 -23
  70. package/build/src/grants/client_credentials_grant.d.ts +0 -23
  71. package/build/src/grants/refresh_token_grant.d.ts +0 -27
  72. package/build/token_controller-DzcrLMyS.js +0 -194
  73. package/build/token_service-fhoA4slP.js +0 -31
@@ -1,4 +1,6 @@
1
1
  import type { HttpContext } from '@adonisjs/core/http';
2
+ import type { JWK } from 'jose';
3
+ import type { OAuthUserProviderContract } from './guard/types.ts';
2
4
  /**
3
5
  * Augment this interface via module augmentation to enable
4
6
  * type-safe scope names across the application.
@@ -47,6 +49,46 @@ export type InferScopes<T extends {
47
49
  * @see https://datatracker.ietf.org/doc/html/rfc6749#section-5.1
48
50
  */
49
51
  export declare const BUILTIN_SCOPES: Set<string>;
52
+ /**
53
+ * OIDC-recognized scopes that are accepted by server-level validation
54
+ * without needing to be declared in the config `scopes` map.
55
+ *
56
+ * Unlike `BUILTIN_SCOPES`, these still require explicit client authorization
57
+ * via `ClientService.validateClientScopes()`.
58
+ */
59
+ export declare const OIDC_SCOPES: Set<string>;
60
+ /**
61
+ * Protocol-managed claims that must never be overridden by `getOidcClaims()`.
62
+ */
63
+ export declare const RESERVED_OIDC_CLAIMS: Set<string>;
64
+ /**
65
+ * Interface for User models that provide OIDC claims.
66
+ * Implement this on your User model to include custom claims
67
+ * in id_tokens and /userinfo responses.
68
+ *
69
+ * If not implemented, only protocol-level claims (sub, iss, aud, exp, iat)
70
+ * are included.
71
+ */
72
+ export interface OidcSubject {
73
+ getOidcClaims(scopes: Scope[]): Record<string, unknown> | Promise<Record<string, unknown>>;
74
+ }
75
+ /**
76
+ * Collect OIDC claims based on the granted scopes.
77
+ *
78
+ * Maps each scope to its corresponding claims object and merges
79
+ * only the claims for scopes present in the granted set.
80
+ *
81
+ * @example
82
+ * ```ts
83
+ * getOidcClaims(scopes: Scope[]) {
84
+ * return collectOidcClaims(scopes, {
85
+ * profile: { name: this.fullName },
86
+ * email: { email: this.email },
87
+ * })
88
+ * }
89
+ * ```
90
+ */
91
+ export declare function collectOidcClaims(scopes: Scope[], claimsMap: Partial<Record<Scope, Record<string, unknown>>>): Record<string, unknown>;
50
92
  /**
51
93
  * Supported OAuth 2.1 grant types.
52
94
  *
@@ -59,6 +101,39 @@ export declare const BUILTIN_SCOPES: Set<string>;
59
101
  * @see https://datatracker.ietf.org/doc/html/rfc6749#section-6
60
102
  */
61
103
  export type GrantType = 'authorization_code' | 'refresh_token' | 'client_credentials';
104
+ /**
105
+ * Options for creating an OAuth client programmatically.
106
+ */
107
+ export interface CreateClientOptions {
108
+ name: string;
109
+ redirectUris: string[];
110
+ scopes?: string[];
111
+ grantTypes?: GrantType[];
112
+ isPublic?: boolean;
113
+ requirePkce?: boolean;
114
+ userId?: string;
115
+ metadata?: Record<string, any>;
116
+ }
117
+ /**
118
+ * Options for updating an existing OAuth client.
119
+ */
120
+ export interface UpdateClientOptions {
121
+ name?: string;
122
+ redirectUris?: string[];
123
+ scopes?: string[];
124
+ grantTypes?: GrantType[];
125
+ isDisabled?: boolean;
126
+ requirePkce?: boolean;
127
+ metadata?: Record<string, any>;
128
+ }
129
+ /**
130
+ * Result returned when creating a client.
131
+ * Includes the raw secret (only available at creation time).
132
+ */
133
+ export interface CreateClientResult {
134
+ client: import('./models/oauth_client.ts').OAuthClient;
135
+ clientSecret: string | null;
136
+ }
62
137
  /**
63
138
  * User-facing configuration interface for Sésame.
64
139
  *
@@ -100,6 +175,18 @@ export interface SesameConfig {
100
175
  * Defaults to '30d'.
101
176
  */
102
177
  refreshTokenTtl?: string;
178
+ /**
179
+ * Grace period (in seconds) during which a recently-rotated
180
+ * refresh token is still accepted instead of triggering
181
+ * replay-attack detection. Handles race conditions in
182
+ * multi-process clients (e.g. MCP SDK).
183
+ *
184
+ * Set to `0` to disable (strict rotation, no grace period).
185
+ * Defaults to `120` (2 minutes).
186
+ *
187
+ * @see https://auth0.com/docs/secure/tokens/refresh-tokens/configure-refresh-token-rotation
188
+ */
189
+ refreshTokenRotationGracePeriod?: number;
103
190
  /**
104
191
  * Access token TTL for the client_credentials grant (M2M).
105
192
  * Defaults to `accessTokenTtl`.
@@ -142,6 +229,27 @@ export interface SesameConfig {
142
229
  * Defaults to `false`.
143
230
  */
144
231
  allowPublicRegistration?: boolean;
232
+ /**
233
+ * JWK (JSON Web Key) for signing ID tokens.
234
+ * Must be an RSA private key in JWK format.
235
+ * Required together with `oidcProvider` when OIDC scopes (openid) are used.
236
+ * Passed via env var, parsed at boot, lives in memory.
237
+ */
238
+ jwk?: JWK;
239
+ /**
240
+ * User provider used by OIDC flows to resolve the subject for
241
+ * `id_token` emission and `/userinfo`.
242
+ *
243
+ * This is independent from `@adonisjs/auth` guards. The provider is
244
+ * configured once for the authorization server and must be stable for
245
+ * the issuer. Required together with `jwk` to enable OIDC.
246
+ */
247
+ oidcProvider?: OAuthUserProviderContract<unknown>;
248
+ /**
249
+ * ID token TTL as a string duration (e.g. '1h', '10m').
250
+ * Defaults to '1h'.
251
+ */
252
+ idTokenTtl?: string;
145
253
  }
146
254
  /**
147
255
  * Fully resolved configuration with defaults applied.
@@ -155,12 +263,16 @@ export interface ResolvedSesameConfig {
155
263
  accessTokenTtl: string;
156
264
  clientCredentialsAccessTokenTtl: string;
157
265
  refreshTokenTtl: string;
266
+ refreshTokenRotationGracePeriod: number;
158
267
  authorizationCodeTtl: string;
159
268
  authorizationRequestTtl: string;
160
269
  loginPage: string | ((ctx: HttpContext, params: URLSearchParams) => string);
161
270
  consentPage: string | ((ctx: HttpContext, params: URLSearchParams) => string);
162
271
  allowDynamicRegistration: boolean;
163
272
  allowPublicRegistration: boolean;
273
+ jwk?: JWK;
274
+ oidcProvider?: OAuthUserProviderContract<unknown>;
275
+ idTokenTtl: string;
164
276
  }
165
277
  /**
166
278
  * OAuth 2.0 Authorization Server Metadata as defined by RFC 8414.
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Path to the root directory where the stubs are stored. We use
3
+ * this path within commands and the configure hook
4
+ */
5
+ export const stubsRoot = import.meta.dirname
@@ -15,6 +15,7 @@ export default class extends BaseSchema {
15
15
  table.text('redirect_uri').notNullable()
16
16
  table.string('code_challenge').nullable()
17
17
  table.string('code_challenge_method').nullable()
18
+ table.string('nonce').nullable()
18
19
  table.timestamp('expires_at').notNullable()
19
20
  table.timestamp('created_at').notNullable()
20
21
  table.timestamp('updated_at').notNullable()
@@ -21,6 +21,7 @@ export default class extends BaseSchema {
21
21
  table.string('state').nullable()
22
22
  table.string('code_challenge').nullable()
23
23
  table.string('code_challenge_method').nullable()
24
+ table.string('nonce').nullable()
24
25
  table.timestamp('expires_at').notNullable()
25
26
  table.timestamp('created_at').notNullable()
26
27
  })
@@ -9,7 +9,7 @@ export default class extends BaseSchema {
9
9
  this.schema.createTable(this.tableName, (table) => {
10
10
  table.uuid('id').primary()
11
11
  table.string('token').notNullable().index()
12
- table.string('access_token_id').notNullable()
12
+ table.uuid('access_token_id').notNullable()
13
13
  table.string('client_id').notNullable().references('client_id').inTable('oauth_clients').onDelete('CASCADE')
14
14
  table.string('user_id').notNullable()
15
15
  table.json('scopes').notNullable()
@@ -0,0 +1,481 @@
1
+ import "./chunk-DF48asd8.js";
2
+ import { a as OAuthRefreshToken, c as OIDC_SCOPES, i as OAuthAuthorizationCode, o as ClientService, s as BUILTIN_SCOPES, t as SesameManager } from "./sesame_manager-DYUSZ0NC.js";
3
+ import "./oauth_client-BSanvSql.js";
4
+ import { a as E_INVALID_GRANT, o as E_INVALID_REQUEST, r as E_INVALID_CLIENT, s as E_INVALID_SCOPE, u as E_UNSUPPORTED_GRANT_TYPE } from "./oauth_error-C7UhDb2q.js";
5
+ import { t as OAuthAccessToken } from "./oauth_access_token-Cz_5gNBx.js";
6
+ import { t as TokenService } from "./token_service-DwnfAR9F.js";
7
+ import { t as IdTokenService } from "./id_token_service-CpTzOUDe.js";
8
+ import { DateTime } from "luxon";
9
+ import { createHash } from "node:crypto";
10
+ import string from "@adonisjs/core/helpers/string";
11
+ import vine from "@vinejs/vine";
12
+ //#region src/actions/exchange_authorization_code.ts
13
+ /**
14
+ * Validates the code_verifier format per RFC 7636 §4.1.
15
+ * 43-128 characters from the unreserved character set [A-Za-z0-9-._~].
16
+ */
17
+ const codeVerifierValidator = vine.create({ code_verifier: vine.string().minLength(43).maxLength(128).regex(/^[A-Za-z0-9\-._~]+$/) });
18
+ /**
19
+ * Handle the Authorization Code Grant (RFC 6749 §4.1.3).
20
+ *
21
+ * Exchanges an authorization code for an access token and
22
+ * optionally a refresh token and id_token. Verifies PKCE,
23
+ * validates scopes, and atomically consumes the code to
24
+ * prevent replay.
25
+ *
26
+ * @see https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3
27
+ * @see https://datatracker.ietf.org/doc/html/rfc7636#section-4.6
28
+ */
29
+ var ExchangeAuthorizationCodeAction = class {
30
+ /**
31
+ * Exchange an authorization code for tokens. The code is
32
+ * consumed atomically inside a transaction.
33
+ */
34
+ async execute(manager, input) {
35
+ const tokenService = new TokenService(manager);
36
+ const clientService = new ClientService();
37
+ if (!input.client.grantTypes.includes("authorization_code")) throw new E_INVALID_CLIENT("Client is not allowed to use the authorization_code grant");
38
+ const authCode = await this.#validateAuthorizationCode(tokenService, input);
39
+ await this.#verifyPkce(input.codeVerifier, authCode);
40
+ clientService.validateClientScopes(authCode.scopes, input.client.scopes);
41
+ const accessToken = tokenService.createAccessToken();
42
+ const refreshToken = this.#prepareRefreshToken(manager, tokenService);
43
+ const idToken = await this.#prepareIdToken(manager, authCode, input.client, accessToken.raw);
44
+ await this.#atomicExchange(input, authCode, accessToken, refreshToken);
45
+ const ttlSeconds = string.seconds.parse(manager.config.accessTokenTtl);
46
+ return {
47
+ access_token: accessToken.raw,
48
+ token_type: "Bearer",
49
+ expires_in: ttlSeconds,
50
+ scope: authCode.scopes.join(" "),
51
+ ...refreshToken ? { refresh_token: refreshToken.raw } : {},
52
+ ...idToken ? { id_token: idToken } : {}
53
+ };
54
+ }
55
+ /**
56
+ * Lookup the authorization code by hash and validate
57
+ * it has not expired and matches the redirect_uri.
58
+ */
59
+ async #validateAuthorizationCode(tokenService, input) {
60
+ if (!input.code) throw new E_INVALID_REQUEST("Missing required parameter: code");
61
+ if (!input.redirectUri) throw new E_INVALID_REQUEST("Missing required parameter: redirect_uri");
62
+ const hashedCode = tokenService.hashToken(input.code);
63
+ const authCode = await OAuthAuthorizationCode.query().where("code", hashedCode).where("clientId", input.client.clientId).first();
64
+ if (!authCode) throw new E_INVALID_GRANT("Authorization code not found");
65
+ if (authCode.expiresAt < DateTime.now()) {
66
+ await OAuthAuthorizationCode.query().where("id", authCode.id).delete();
67
+ throw new E_INVALID_GRANT("Authorization code has expired");
68
+ }
69
+ if (authCode.redirectUri !== input.redirectUri) throw new E_INVALID_GRANT("Redirect URI mismatch");
70
+ return authCode;
71
+ }
72
+ /**
73
+ * Verify the PKCE code_verifier against the stored
74
+ * code_challenge using S256 (mandatory per OAuth 2.1).
75
+ */
76
+ async #verifyPkce(codeVerifier, authCode) {
77
+ const [verifierError] = await codeVerifierValidator.tryValidate({ code_verifier: codeVerifier });
78
+ if (verifierError) {
79
+ await OAuthAuthorizationCode.query().where("id", authCode.id).delete();
80
+ throw new E_INVALID_REQUEST("code_verifier must be 43-128 characters using only [A-Za-z0-9-._~] (RFC 7636 §4.1)");
81
+ }
82
+ if (!authCode.codeChallenge) {
83
+ await OAuthAuthorizationCode.query().where("id", authCode.id).delete();
84
+ throw new E_INVALID_GRANT("Authorization code is missing PKCE challenge");
85
+ }
86
+ if (createHash("sha256").update(codeVerifier).digest("base64url") !== authCode.codeChallenge) {
87
+ await OAuthAuthorizationCode.query().where("id", authCode.id).delete();
88
+ throw new E_INVALID_GRANT("PKCE verification failed");
89
+ }
90
+ }
91
+ /**
92
+ * Precompute refresh token values if the refresh_token
93
+ * grant type is enabled on the server.
94
+ */
95
+ #prepareRefreshToken(manager, tokenService) {
96
+ if (!manager.isGrantTypeEnabled("refresh_token")) return null;
97
+ const { raw, hash } = tokenService.createRefreshToken();
98
+ const refreshTtl = string.seconds.parse(manager.config.refreshTokenTtl);
99
+ return {
100
+ raw,
101
+ hash,
102
+ expiresAt: DateTime.now().plus({ seconds: refreshTtl })
103
+ };
104
+ }
105
+ /**
106
+ * Build an id_token JWT if the authorization code was
107
+ * granted the openid scope.
108
+ */
109
+ async #prepareIdToken(manager, authCode, client, accessTokenRaw) {
110
+ if (!authCode.scopes.includes("openid")) return null;
111
+ const idTokenService = new IdTokenService(manager);
112
+ const user = await manager.findUserById(authCode.userId);
113
+ if (!user) throw new E_INVALID_GRANT("OIDC user not found");
114
+ return idTokenService.sign({
115
+ sub: authCode.userId,
116
+ clientId: client.clientId,
117
+ scopes: authCode.scopes,
118
+ accessToken: accessTokenRaw,
119
+ user,
120
+ nonce: authCode.nonce ?? void 0
121
+ });
122
+ }
123
+ /**
124
+ * Atomically consume the authorization code and persist
125
+ * the new access token (and optionally refresh token)
126
+ * inside a single transaction.
127
+ */
128
+ async #atomicExchange(input, authCode, accessToken, refreshToken) {
129
+ await OAuthAuthorizationCode.transaction(async (trx) => {
130
+ const deleteResult = await OAuthAuthorizationCode.query({ client: trx }).where("id", authCode.id).delete();
131
+ if ((Array.isArray(deleteResult) ? Number(deleteResult[0] ?? 0) : Number(deleteResult)) !== 1) throw new E_INVALID_GRANT("Authorization code has already been consumed");
132
+ const accessTokenId = crypto.randomUUID();
133
+ await OAuthAccessToken.create({
134
+ id: accessTokenId,
135
+ tokenHash: accessToken.hash,
136
+ clientId: input.client.clientId,
137
+ userId: authCode.userId,
138
+ scopes: authCode.scopes,
139
+ expiresAt: DateTime.fromJSDate(accessToken.expiresAt)
140
+ }, { client: trx });
141
+ if (refreshToken) await OAuthRefreshToken.create({
142
+ id: crypto.randomUUID(),
143
+ token: refreshToken.hash,
144
+ accessTokenId,
145
+ clientId: input.client.clientId,
146
+ userId: authCode.userId,
147
+ scopes: authCode.scopes,
148
+ expiresAt: refreshToken.expiresAt
149
+ }, { client: trx });
150
+ });
151
+ }
152
+ };
153
+ //#endregion
154
+ //#region src/actions/exchange_refresh_token.ts
155
+ /**
156
+ * Handle the Refresh Token Grant (RFC 6749 §6).
157
+ *
158
+ * Exchanges a refresh token for a new access token and a new
159
+ * refresh token (rotation). The old refresh token is revoked
160
+ * immediately after use.
161
+ *
162
+ * ## Replay detection
163
+ *
164
+ * If a revoked refresh token is presented **outside** the grace
165
+ * period, all tokens for that client+user pair are nuked to
166
+ * mitigate stolen-token reuse (RFC 6819 §5.2.2.3, RFC 9700 §4.14.2).
167
+ *
168
+ * ## Grace period (rotation reuse window)
169
+ *
170
+ * OAuth 2.1 requires that rotated refresh tokens be single-use.
171
+ * However, that requirement conflicts with the realities of
172
+ * distributed systems: if the server rotates the token but the
173
+ * client never receives (or persists) the new token — due to a
174
+ * network failure, a concurrent refresh from another process, or
175
+ * a retry after timeout — the client loses its grant permanently.
176
+ *
177
+ * To handle this, we allow a recently-rotated refresh token to be
178
+ * reused within a short configurable window (`refreshTokenRotationGracePeriod`,
179
+ * defaults to 120 s). During that window the old token issues fresh
180
+ * tokens without triggering replay-attack revocation.
181
+ *
182
+ * This is the same approach used by Auth0 ("reuse interval") and
183
+ * Cloudflare workers-oauth-provider ("previous token"). It provides
184
+ * most of the security benefits of strict rotation while remaining
185
+ * reliable for real-world clients (MCP SDK, multi-process CLIs, etc.).
186
+ *
187
+ * @see https://datatracker.ietf.org/doc/html/rfc6749#section-6
188
+ * @see https://datatracker.ietf.org/doc/html/rfc6819#section-5.2.2.3
189
+ * @see https://datatracker.ietf.org/doc/html/rfc9700#section-4.14.2
190
+ */
191
+ var ExchangeRefreshTokenAction = class {
192
+ /**
193
+ * Rotate the refresh token, issue a new access token,
194
+ * and optionally reissue an id_token if openid scope
195
+ * is present.
196
+ */
197
+ async execute(manager, input) {
198
+ const tokenService = new TokenService(manager);
199
+ const clientService = new ClientService();
200
+ if (!input.refreshToken) throw new E_INVALID_REQUEST("Missing required parameter: refresh_token");
201
+ if (!input.client.grantTypes.includes("refresh_token")) throw new E_INVALID_CLIENT("Client is not allowed to use the refresh_token grant");
202
+ const hashedToken = tokenService.hashToken(input.refreshToken);
203
+ const refreshToken = await OAuthRefreshToken.query().where("token", hashedToken).where("clientId", input.client.clientId).first();
204
+ if (!refreshToken) throw new E_INVALID_GRANT("Refresh token not found");
205
+ if (refreshToken.revokedAt) {
206
+ const gracePeriodSeconds = manager.config.refreshTokenRotationGracePeriod;
207
+ const revokedSecondsAgo = DateTime.now().diff(refreshToken.revokedAt, "seconds").seconds;
208
+ if (gracePeriodSeconds <= 0 || revokedSecondsAgo > gracePeriodSeconds) {
209
+ await this.#nukeTokensForReplay(input.client.clientId, refreshToken.userId);
210
+ throw new E_INVALID_GRANT("Refresh token has been revoked (possible replay attack)");
211
+ }
212
+ /**
213
+ * Within grace period — the old refresh token was recently
214
+ * rotated and a concurrent client is reusing it. Issue new
215
+ * tokens without nuking the family. This matches Auth0 / Okta
216
+ * behavior for handling race conditions in multi-process
217
+ * clients (e.g. MCP SDK proactive refresh + SDK 401 retry).
218
+ */
219
+ return this.#issueFreshTokens(manager, input, refreshToken);
220
+ }
221
+ if (refreshToken.expiresAt < DateTime.now()) throw new E_INVALID_GRANT("Refresh token has expired");
222
+ const scopes = this.#resolveScopes(manager, input, refreshToken, clientService);
223
+ const accessToken = tokenService.createAccessToken();
224
+ const newRefreshToken = this.#prepareRefreshToken(manager, tokenService);
225
+ const idToken = await this.#prepareIdToken(manager, scopes, refreshToken, input.client, accessToken.raw);
226
+ await this.#atomicRotation(input, refreshToken, accessToken, newRefreshToken, scopes);
227
+ const ttlSeconds = string.seconds.parse(manager.config.accessTokenTtl);
228
+ return {
229
+ access_token: accessToken.raw,
230
+ token_type: "Bearer",
231
+ expires_in: ttlSeconds,
232
+ scope: scopes.join(" "),
233
+ refresh_token: newRefreshToken.raw,
234
+ ...idToken ? { id_token: idToken } : {}
235
+ };
236
+ }
237
+ /**
238
+ * Grace-period reuse: the old refresh token was rotated
239
+ * recently and a concurrent client replayed it. Issue a
240
+ * brand-new AT + RT pair directly (the old pair is already
241
+ * revoked from the first rotation).
242
+ */
243
+ async #issueFreshTokens(manager, input, revokedRefreshToken) {
244
+ const tokenService = new TokenService(manager);
245
+ const clientService = new ClientService();
246
+ const scopes = this.#resolveScopes(manager, input, revokedRefreshToken, clientService);
247
+ const accessToken = tokenService.createAccessToken();
248
+ const newRefreshToken = this.#prepareRefreshToken(manager, tokenService);
249
+ const idToken = await this.#prepareIdToken(manager, scopes, revokedRefreshToken, input.client, accessToken.raw);
250
+ const accessTokenId = crypto.randomUUID();
251
+ await OAuthAccessToken.create({
252
+ id: accessTokenId,
253
+ tokenHash: accessToken.hash,
254
+ clientId: input.client.clientId,
255
+ userId: revokedRefreshToken.userId,
256
+ scopes,
257
+ expiresAt: DateTime.fromJSDate(accessToken.expiresAt)
258
+ });
259
+ await OAuthRefreshToken.create({
260
+ id: crypto.randomUUID(),
261
+ token: newRefreshToken.hash,
262
+ accessTokenId,
263
+ clientId: input.client.clientId,
264
+ userId: revokedRefreshToken.userId,
265
+ scopes,
266
+ expiresAt: newRefreshToken.expiresAt
267
+ });
268
+ const ttlSeconds = string.seconds.parse(manager.config.accessTokenTtl);
269
+ return {
270
+ access_token: accessToken.raw,
271
+ token_type: "Bearer",
272
+ expires_in: ttlSeconds,
273
+ scope: scopes.join(" "),
274
+ refresh_token: newRefreshToken.raw,
275
+ ...idToken ? { id_token: idToken } : {}
276
+ };
277
+ }
278
+ /**
279
+ * Replay detection: nuke all tokens for this client+user
280
+ * pair when a revoked token is reused.
281
+ */
282
+ async #nukeTokensForReplay(clientId, userId) {
283
+ await OAuthRefreshToken.query().where("clientId", clientId).where("userId", userId).delete();
284
+ await OAuthAccessToken.query().where("clientId", clientId).where("userId", userId).whereNull("revokedAt").update({ revokedAt: DateTime.now().toSQL() });
285
+ }
286
+ /**
287
+ * Resolve the effective scopes for the new token. Supports
288
+ * scope narrowing but rejects scope escalation.
289
+ */
290
+ #resolveScopes(manager, input, refreshToken, clientService) {
291
+ if (!input.scope) {
292
+ clientService.validateClientScopes(refreshToken.scopes, input.client.scopes);
293
+ return refreshToken.scopes;
294
+ }
295
+ const requested = input.scope.split(" ");
296
+ const originalSet = new Set(refreshToken.scopes);
297
+ const invalid = requested.filter((s) => !originalSet.has(s));
298
+ if (invalid.length > 0) throw new E_INVALID_SCOPE(`Scope not in original grant: ${invalid.join(", ")}`);
299
+ const invalidScopes = manager.validateScopes(requested);
300
+ if (invalidScopes.length > 0) throw new E_INVALID_SCOPE(`Invalid scopes: ${invalidScopes.join(", ")}`);
301
+ if (manager.usesOidcScopes(requested) && !manager.isOidcEnabled) throw new E_INVALID_SCOPE("OIDC scopes require OIDC to be configured (set jwk and oidcProvider in config)");
302
+ clientService.validateClientScopes(requested, input.client.scopes);
303
+ return requested;
304
+ }
305
+ /**
306
+ * Precompute the new refresh token values for rotation.
307
+ */
308
+ #prepareRefreshToken(manager, tokenService) {
309
+ const { raw, hash } = tokenService.createRefreshToken();
310
+ const refreshTtl = string.seconds.parse(manager.config.refreshTokenTtl);
311
+ return {
312
+ raw,
313
+ hash,
314
+ expiresAt: DateTime.now().plus({ seconds: refreshTtl })
315
+ };
316
+ }
317
+ /**
318
+ * Reissue an id_token if openid scope is present.
319
+ * No nonce on refresh per OIDC Core §12.2.
320
+ */
321
+ async #prepareIdToken(manager, scopes, refreshToken, client, accessTokenRaw) {
322
+ if (!scopes.includes("openid")) return null;
323
+ const idTokenService = new IdTokenService(manager);
324
+ const user = await manager.findUserById(refreshToken.userId);
325
+ if (!user) throw new E_INVALID_GRANT("OIDC user not found");
326
+ return idTokenService.sign({
327
+ sub: refreshToken.userId,
328
+ clientId: client.clientId,
329
+ scopes,
330
+ accessToken: accessTokenRaw,
331
+ user
332
+ });
333
+ }
334
+ /**
335
+ * Atomically revoke the old token pair and persist the
336
+ * new access + refresh tokens inside a single transaction.
337
+ */
338
+ async #atomicRotation(input, oldRefreshToken, accessToken, newRefreshToken, scopes) {
339
+ await OAuthRefreshToken.transaction(async (trx) => {
340
+ const revokedAt = DateTime.now();
341
+ const updateResult = await OAuthRefreshToken.query({ client: trx }).where("id", oldRefreshToken.id).whereNull("revokedAt").update({ revokedAt: revokedAt.toSQL() });
342
+ if ((Array.isArray(updateResult) ? Number(updateResult[0] ?? 0) : Number(updateResult)) !== 1) throw new E_INVALID_GRANT("Refresh token has already been consumed");
343
+ await OAuthAccessToken.query({ client: trx }).where("id", oldRefreshToken.accessTokenId).whereNull("revokedAt").update({ revokedAt: revokedAt.toSQL() });
344
+ const accessTokenId = crypto.randomUUID();
345
+ await OAuthAccessToken.create({
346
+ id: accessTokenId,
347
+ tokenHash: accessToken.hash,
348
+ clientId: input.client.clientId,
349
+ userId: oldRefreshToken.userId,
350
+ scopes,
351
+ expiresAt: DateTime.fromJSDate(accessToken.expiresAt)
352
+ }, { client: trx });
353
+ await OAuthRefreshToken.create({
354
+ id: crypto.randomUUID(),
355
+ token: newRefreshToken.hash,
356
+ accessTokenId,
357
+ clientId: input.client.clientId,
358
+ userId: oldRefreshToken.userId,
359
+ scopes,
360
+ expiresAt: newRefreshToken.expiresAt
361
+ }, { client: trx });
362
+ });
363
+ }
364
+ };
365
+ //#endregion
366
+ //#region src/actions/exchange_client_credentials.ts
367
+ /**
368
+ * Handle the Client Credentials Grant (RFC 6749 §4.4).
369
+ *
370
+ * Issues an access token directly to a confidential client
371
+ * for machine-to-machine communication. No refresh token
372
+ * is issued. User-centric OIDC scopes are rejected.
373
+ *
374
+ * @see https://datatracker.ietf.org/doc/html/rfc6749#section-4.4
375
+ */
376
+ var ExchangeClientCredentialsAction = class {
377
+ /**
378
+ * Validate the client, resolve scopes, and issue an
379
+ * access token for M2M usage.
380
+ */
381
+ async execute(manager, input) {
382
+ const clientService = new ClientService();
383
+ if (input.client.isPublic) throw new E_INVALID_CLIENT("Public clients cannot use the client_credentials grant");
384
+ if (!input.client.grantTypes.includes("client_credentials")) throw new E_INVALID_CLIENT("Client is not allowed to use the client_credentials grant");
385
+ const scopes = this.#resolveScopes(manager, input, clientService);
386
+ if (!input.client.userId) throw new E_INVALID_CLIENT("Client must be associated with a user to use the client_credentials grant");
387
+ const tokenService = new TokenService(manager);
388
+ const ttlSeconds = string.seconds.parse(manager.config.clientCredentialsAccessTokenTtl);
389
+ const { raw: accessTokenRaw, hash: tokenHash } = tokenService.createAccessToken();
390
+ const expiresAt = new Date(Date.now() + ttlSeconds * 1e3);
391
+ await OAuthAccessToken.create({
392
+ id: crypto.randomUUID(),
393
+ tokenHash,
394
+ clientId: input.client.clientId,
395
+ userId: input.client.userId,
396
+ scopes,
397
+ expiresAt: DateTime.fromJSDate(expiresAt)
398
+ });
399
+ return {
400
+ access_token: accessTokenRaw,
401
+ token_type: "Bearer",
402
+ expires_in: ttlSeconds,
403
+ scope: scopes.join(" ")
404
+ };
405
+ }
406
+ /**
407
+ * Resolve and validate scopes. Falls back to the client's
408
+ * non-OIDC scopes when none are requested. Rejects any
409
+ * user-centric OIDC scopes.
410
+ */
411
+ #resolveScopes(manager, input, clientService) {
412
+ const requestedScopes = input.scope ? input.scope.split(" ") : input.client.scopes.filter((scope) => !BUILTIN_SCOPES.has(scope) && !OIDC_SCOPES.has(scope));
413
+ const forbiddenRequested = requestedScopes.filter((scope) => BUILTIN_SCOPES.has(scope) || OIDC_SCOPES.has(scope));
414
+ if (forbiddenRequested.length > 0) throw new E_INVALID_SCOPE(`Scopes not allowed for client_credentials: ${forbiddenRequested.join(", ")}`);
415
+ const invalidScopes = manager.validateScopes(requestedScopes);
416
+ if (invalidScopes.length > 0) throw new E_INVALID_SCOPE(`Invalid scopes: ${invalidScopes.join(", ")}`);
417
+ clientService.validateClientScopes(requestedScopes, input.client.scopes);
418
+ return requestedScopes;
419
+ }
420
+ };
421
+ //#endregion
422
+ //#region src/controllers/token_controller.ts
423
+ /**
424
+ * Handles the OAuth 2.0 Token Endpoint (RFC 6749 §3.2).
425
+ *
426
+ * Authenticates the client, extracts grant-specific params,
427
+ * and dispatches to the appropriate action. Sets required
428
+ * cache headers on all token responses.
429
+ *
430
+ * @see https://datatracker.ietf.org/doc/html/rfc6749#section-3.2
431
+ */
432
+ var TokenController = class TokenController {
433
+ static validator = vine.create({ grant_type: vine.string() });
434
+ /**
435
+ * Validate the grant type, authenticate the client,
436
+ * and dispatch to the appropriate grant action.
437
+ */
438
+ async handle(ctx) {
439
+ const manager = await ctx.containerResolver.make(SesameManager);
440
+ const body = ctx.request.body();
441
+ const [error, validated] = await TokenController.validator.tryValidate(body);
442
+ if (error) throw new E_UNSUPPORTED_GRANT_TYPE("Missing required parameter: grant_type");
443
+ if (!manager.isGrantTypeEnabled(validated.grant_type)) throw new E_UNSUPPORTED_GRANT_TYPE(`Grant type "${validated.grant_type}" is not enabled`);
444
+ const client = await new ClientService().authenticateClient({
445
+ authorizationHeader: ctx.request.header("authorization"),
446
+ bodyClientId: body.client_id,
447
+ bodyClientSecret: body.client_secret
448
+ });
449
+ const result = await this.#dispatchGrant(validated.grant_type, manager, client, body);
450
+ ctx.response.header("Cache-Control", "no-store");
451
+ ctx.response.header("Pragma", "no-cache");
452
+ return result;
453
+ }
454
+ /**
455
+ * Map each grant type to its action, extracting the
456
+ * relevant params from the request body.
457
+ */
458
+ #dispatchGrant(grantType, manager, client, body) {
459
+ const handler = {
460
+ authorization_code: () => new ExchangeAuthorizationCodeAction().execute(manager, {
461
+ client,
462
+ code: body.code,
463
+ redirectUri: body.redirect_uri,
464
+ codeVerifier: body.code_verifier
465
+ }),
466
+ refresh_token: () => new ExchangeRefreshTokenAction().execute(manager, {
467
+ client,
468
+ refreshToken: body.refresh_token,
469
+ scope: body.scope
470
+ }),
471
+ client_credentials: () => new ExchangeClientCredentialsAction().execute(manager, {
472
+ client,
473
+ scope: body.scope
474
+ })
475
+ }[grantType];
476
+ if (!handler) throw new E_UNSUPPORTED_GRANT_TYPE(`Unsupported grant type: ${grantType}`);
477
+ return handler();
478
+ }
479
+ };
480
+ //#endregion
481
+ export { TokenController as default };