@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.
- package/LICENSE.md +1 -1
- package/README.md +405 -62
- package/build/authorize_controller-BiycO4be.js +251 -0
- package/build/chunk-DF48asd8.js +9 -0
- package/build/{client_info_controller-BucHGx4u.js → client_info_controller-AcOG8lWu.js} +11 -3
- package/build/commands/sesame_client.d.ts +20 -0
- package/build/commands/sesame_key.d.ts +12 -0
- package/build/commands/sesame_purge.d.ts +0 -2
- package/build/commands/sesame_purge.js +15 -3
- package/build/configure-DkDkIlt8.js +27 -0
- package/build/configure.js +2 -24
- package/build/consent_controller-Dsdhv6-f.js +108 -0
- package/build/id_token_service-CpTzOUDe.js +54 -0
- package/build/index.d.ts +1 -1
- package/build/index.js +30 -10
- package/build/{introspect_controller-6bRt9sZt.js → introspect_controller-DvOp9scr.js} +21 -7
- package/build/issue_authorization_code-B9ERu1uO.js +40 -0
- package/build/jwks_controller-keo4kBZc.js +26 -0
- package/build/{main-EbeMS5S9.js → main-DGBJhq3E.js} +34 -4
- package/build/{metadata_controller-DeaMRnUr.js → metadata_controller-BVsTo0Gp.js} +83 -6
- package/build/{oauth_access_token-bsoM5KeU.js → oauth_access_token-Cz_5gNBx.js} +12 -1
- package/build/oauth_client-BSanvSql.js +63 -0
- package/build/oauth_error-C7UhDb2q.js +189 -0
- package/build/providers/sesame_provider.js +14 -3
- package/build/{register_controller-sIJ1rxdM.js → register_controller-gbq7p8a5.js} +46 -7
- package/build/{revoke_controller-D6isoQCi.js → revoke_controller-z_ghrEB7.js} +21 -8
- package/build/services/main.js +7 -3
- package/build/sesame_manager-B1Jgq1v2.js +6 -0
- package/build/sesame_manager-DYUSZ0NC.js +693 -0
- package/build/src/actions/authorize.d.ts +46 -0
- package/build/src/actions/exchange_authorization_code.d.ts +34 -0
- package/build/src/actions/exchange_client_credentials.d.ts +28 -0
- package/build/src/actions/exchange_refresh_token.d.ts +59 -0
- package/build/src/actions/issue_authorization_code.d.ts +26 -0
- package/build/src/controllers/authorize_controller.d.ts +13 -12
- package/build/src/controllers/consent_controller.d.ts +5 -0
- package/build/src/controllers/jwks_controller.d.ts +14 -0
- package/build/src/controllers/metadata_controller.d.ts +8 -1
- package/build/src/controllers/token_controller.d.ts +8 -5
- package/build/src/controllers/userinfo_controller.d.ts +14 -0
- package/build/src/guard/main.js +5 -5
- package/build/src/middleware/any_scope_middleware.js +11 -1
- package/build/src/middleware/scope_middleware.js +11 -1
- package/build/src/models/oauth_authorization_code.d.ts +1 -0
- package/build/src/models/oauth_pending_authorization_request.d.ts +1 -0
- package/build/src/oauth_error.d.ts +1 -1
- package/build/src/routes.d.ts +3 -1
- package/build/src/services/id_token_service.d.ts +30 -0
- package/build/src/services/key_service.d.ts +20 -0
- package/build/src/sesame_manager.d.ts +54 -3
- package/build/src/types.d.ts +112 -0
- package/build/stubs/main.ts +5 -0
- package/build/stubs/migrations/create_oauth_authorization_codes_table.stub +1 -0
- package/build/stubs/migrations/create_oauth_pending_authorization_requests_table.stub +1 -0
- package/build/stubs/migrations/create_oauth_refresh_tokens_table.stub +1 -1
- package/build/token_controller-DyI7oy-U.js +481 -0
- package/build/token_service-DwnfAR9F.js +59 -0
- package/build/userinfo_controller-RLk8cN_o.js +40 -0
- package/build/vite.config.d.ts +2 -0
- package/package.json +26 -41
- package/build/authorize_controller-YUfAy-R2.js +0 -138
- package/build/client_service-WTNMqWzY.js +0 -65
- package/build/consent_controller-Dprwd1ed.js +0 -85
- package/build/decorate-BKZEjPRg.js +0 -15
- package/build/oauth_client-BIoY5jBR.js +0 -24
- package/build/oauth_error-CnJ3L8tf.js +0 -94
- package/build/sesame_manager-Bu4MHqZV.js +0 -4
- package/build/sesame_manager-DwDZy5Vy.js +0 -167
- package/build/src/grants/authorization_code_grant.d.ts +0 -23
- package/build/src/grants/client_credentials_grant.d.ts +0 -23
- package/build/src/grants/refresh_token_grant.d.ts +0 -27
- package/build/token_controller-DzcrLMyS.js +0 -194
- package/build/token_service-fhoA4slP.js +0 -31
package/build/src/types.d.ts
CHANGED
|
@@ -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.
|
|
@@ -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.
|
|
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 };
|