@julr/sesame 0.5.0 → 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-un95fs4y.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-B3M6ihoS.js → main-DGBJhq3E.js} +34 -4
- package/build/metadata_controller-BVsTo0Gp.js +158 -0
- 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-Dch4ecyD.js → register_controller-gbq7p8a5.js} +46 -7
- package/build/{revoke_controller-DnPmzYMd.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 +9 -2
- 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 +124 -3
- 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-BGzxPvYU.js +0 -138
- package/build/client_service-C3rfXGk_.js +0 -65
- package/build/consent_controller-BHoB9mip.js +0 -85
- package/build/decorate-BKZEjPRg.js +0 -15
- package/build/metadata_controller-CJeZG93_.js +0 -81
- package/build/oauth_client-BIoY5jBR.js +0 -24
- package/build/oauth_error-CnJ3L8tf.js +0 -94
- package/build/sesame_manager-BQIW2mqt.js +0 -4
- package/build/sesame_manager-C-eEFFHM.js +0 -167
- package/build/src/grants/authorization_code_grant.d.ts +0 -27
- 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-hGDAYuBS.js +0 -194
- package/build/token_service-fhoA4slP.js +0 -31
|
@@ -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 };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import string from "@adonisjs/core/helpers/string";
|
|
3
|
+
//#region src/services/token_service.ts
|
|
4
|
+
/**
|
|
5
|
+
* Handles opaque token generation for access tokens, refresh tokens,
|
|
6
|
+
* and authorization codes.
|
|
7
|
+
*
|
|
8
|
+
* All tokens are random opaque values. Only their SHA-256 hashes
|
|
9
|
+
* are stored in the database, so the raw tokens cannot be
|
|
10
|
+
* reconstructed from a database leak.
|
|
11
|
+
*/
|
|
12
|
+
var TokenService = class {
|
|
13
|
+
#manager;
|
|
14
|
+
constructor(manager) {
|
|
15
|
+
this.#manager = manager;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Create an opaque access token. Returns the raw token
|
|
19
|
+
* (sent to the client), its SHA-256 hash (stored in DB),
|
|
20
|
+
* and the computed expiration date.
|
|
21
|
+
*/
|
|
22
|
+
createAccessToken() {
|
|
23
|
+
const raw = this.generateOpaqueToken();
|
|
24
|
+
const ttlSeconds = string.seconds.parse(this.#manager.config.accessTokenTtl);
|
|
25
|
+
return {
|
|
26
|
+
raw,
|
|
27
|
+
hash: this.hashToken(raw),
|
|
28
|
+
expiresAt: new Date(Date.now() + ttlSeconds * 1e3)
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Create an opaque refresh token. Returns the raw token
|
|
33
|
+
* (sent to the client) and its SHA-256 hash (stored in DB).
|
|
34
|
+
*/
|
|
35
|
+
createRefreshToken() {
|
|
36
|
+
const raw = this.generateOpaqueToken();
|
|
37
|
+
return {
|
|
38
|
+
raw,
|
|
39
|
+
hash: this.hashToken(raw)
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Generate a cryptographically random opaque token
|
|
44
|
+
* (32 bytes, base64url-encoded).
|
|
45
|
+
*/
|
|
46
|
+
generateOpaqueToken() {
|
|
47
|
+
return randomBytes(32).toString("base64url");
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* SHA-256 hash a token value for secure storage.
|
|
51
|
+
* All tokens (codes, access tokens, refresh tokens) are stored
|
|
52
|
+
* as hashes so raw values cannot be reconstructed from a DB leak.
|
|
53
|
+
*/
|
|
54
|
+
hashToken(token) {
|
|
55
|
+
return createHash("sha256").update(token).digest("base64url");
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
//#endregion
|
|
59
|
+
export { TokenService as t };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import "./chunk-DF48asd8.js";
|
|
2
|
+
import { t as SesameManager } from "./sesame_manager-DYUSZ0NC.js";
|
|
3
|
+
import "./oauth_client-BSanvSql.js";
|
|
4
|
+
import { c as E_INVALID_TOKEN, n as E_INSUFFICIENT_SCOPE, o as E_INVALID_REQUEST } 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
|
+
//#region src/controllers/userinfo_controller.ts
|
|
9
|
+
/**
|
|
10
|
+
* OpenID Connect UserInfo endpoint (OIDC Core §5.3).
|
|
11
|
+
*
|
|
12
|
+
* Returns claims about the authenticated user based on the
|
|
13
|
+
* access token's scopes. Supports both GET and POST.
|
|
14
|
+
*
|
|
15
|
+
* @see https://openid.net/specs/openid-connect-core-1_0.html#UserInfo
|
|
16
|
+
*/
|
|
17
|
+
var UserinfoController = class {
|
|
18
|
+
async handle(ctx) {
|
|
19
|
+
const manager = await ctx.containerResolver.make(SesameManager);
|
|
20
|
+
const authHeader = ctx.request.header("authorization");
|
|
21
|
+
const rawToken = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : ctx.request.body().access_token;
|
|
22
|
+
if (!rawToken) throw new E_INVALID_REQUEST("Missing Bearer token");
|
|
23
|
+
const hashed = new TokenService(manager).hashToken(rawToken);
|
|
24
|
+
const token = await OAuthAccessToken.query().where("tokenHash", hashed).first();
|
|
25
|
+
if (!token) throw new E_INVALID_TOKEN("Invalid access token");
|
|
26
|
+
if (token.revokedAt) throw new E_INVALID_TOKEN("Access token has been revoked");
|
|
27
|
+
if (token.expiresAt.toJSDate() < /* @__PURE__ */ new Date()) throw new E_INVALID_TOKEN("Access token has expired");
|
|
28
|
+
if (!token.scopes.includes("openid")) throw new E_INSUFFICIENT_SCOPE(["openid"], "Token does not have openid scope");
|
|
29
|
+
const user = await manager.findUserById(token.userId);
|
|
30
|
+
if (!user) throw new E_INVALID_TOKEN("Invalid access token");
|
|
31
|
+
const userClaims = await IdTokenService.resolveUserClaims(user, token.scopes);
|
|
32
|
+
ctx.response.header("Content-Type", "application/json");
|
|
33
|
+
return {
|
|
34
|
+
sub: token.userId,
|
|
35
|
+
...userClaims
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
//#endregion
|
|
40
|
+
export { UserinfoController as default };
|