@julr/sesame 0.5.1 → 0.7.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 (97) hide show
  1. package/LICENSE.md +1 -1
  2. package/README.md +470 -64
  3. package/build/authorize_controller-sCZIjnc-.js +251 -0
  4. package/build/{client_info_controller-BucHGx4u.js → client_info_controller-B5A3tpbk.js} +11 -4
  5. package/build/commands/sesame_client.d.ts +20 -0
  6. package/build/commands/sesame_key.d.ts +12 -0
  7. package/build/commands/sesame_purge.d.ts +0 -2
  8. package/build/commands/sesame_purge.js +12 -3
  9. package/build/configure-t9q3KonY.js +31 -0
  10. package/build/configure.js +1 -24
  11. package/build/consent_controller-ChBboaSd.js +96 -0
  12. package/build/consent_retry-DMkfhmk9.js +41 -0
  13. package/build/{decorate-BKZEjPRg.js → decorate-RQD1h28J.js} +3 -9
  14. package/build/id_token_service-BtBUiL_n.js +54 -0
  15. package/build/index.d.ts +2 -8
  16. package/build/index.js +59 -10
  17. package/build/{introspect_controller-6bRt9sZt.js → introspect_controller-LVYGWhMj.js} +28 -10
  18. package/build/issue_authorization_code-Dr4-bbMp.js +39 -0
  19. package/build/jwks_controller-4HUVS_HC.js +22 -0
  20. package/build/{main-EbeMS5S9.js → main-Dx9kZv07.js} +35 -43
  21. package/build/{metadata_controller-DeaMRnUr.js → metadata_controller-hwfmxlF7.js} +81 -6
  22. package/build/oauth_error-CvxtZvjp.js +189 -0
  23. package/build/providers/sesame_provider.d.ts +1 -1
  24. package/build/providers/sesame_provider.js +17 -6
  25. package/build/{register_controller-sIJ1rxdM.js → register_controller-Cdk3c32j.js} +44 -8
  26. package/build/revoke_controller-Daz4Yqfs.js +48 -0
  27. package/build/services/main.js +3 -3
  28. package/build/sesame_manager-CeEmlzba.js +515 -0
  29. package/build/sesame_manager-DQxvsTEq.js +2 -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 +18 -17
  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 +10 -3
  39. package/build/src/controllers/register_controller.d.ts +30 -30
  40. package/build/src/controllers/token_controller.d.ts +8 -5
  41. package/build/src/controllers/userinfo_controller.d.ts +14 -0
  42. package/build/src/guard/guard.d.ts +1 -1
  43. package/build/src/guard/lucid.d.ts +8 -0
  44. package/build/src/guard/lucid.js +46 -0
  45. package/build/src/guard/main.d.ts +2 -9
  46. package/build/src/guard/main.js +2 -6
  47. package/build/src/guard/types.d.ts +0 -9
  48. package/build/src/guard/user_provider.d.ts +9 -1
  49. package/build/src/middleware/any_scope_middleware.js +10 -1
  50. package/build/src/middleware/scope_middleware.js +10 -1
  51. package/build/src/models/oauth_authorization_code.d.ts +1 -0
  52. package/build/src/models/oauth_pending_authorization_request.d.ts +1 -0
  53. package/build/src/oauth_error.d.ts +74 -74
  54. package/build/src/routes.d.ts +3 -1
  55. package/build/src/services/client_service.d.ts +5 -2
  56. package/build/src/services/id_token_service.d.ts +30 -0
  57. package/build/src/services/key_service.d.ts +20 -0
  58. package/build/src/sesame_manager.d.ts +56 -4
  59. package/build/src/storage/consent_retry.d.ts +4 -0
  60. package/build/src/storage/drivers/kysely.d.ts +166 -0
  61. package/build/src/storage/drivers/kysely.js +480 -0
  62. package/build/src/storage/drivers/lucid.d.ts +162 -0
  63. package/build/src/storage/drivers/lucid.js +549 -0
  64. package/build/src/storage/migrations/kysely.d.ts +6 -0
  65. package/build/src/storage/types.d.ts +197 -0
  66. package/build/src/storage/types.js +1 -0
  67. package/build/src/stores.d.ts +21 -0
  68. package/build/src/types.d.ts +120 -0
  69. package/build/src/types.js +2 -0
  70. package/build/stubs/config/sesame.stub +4 -1
  71. package/build/stubs/config/sesame_kysely.stub +35 -0
  72. package/build/stubs/main.ts +5 -0
  73. package/build/stubs/migrations/create_oauth_authorization_codes_table.stub +1 -0
  74. package/build/stubs/migrations/create_oauth_pending_authorization_requests_table.stub +1 -0
  75. package/build/stubs/migrations/create_oauth_refresh_tokens_table.stub +1 -1
  76. package/build/stubs/migrations/kysely/create_oauth_tables.stub +151 -0
  77. package/build/taze.config.d.ts +2 -0
  78. package/build/token_controller-DRatCQNT.js +492 -0
  79. package/build/token_service-DDQ3Dxaj.js +59 -0
  80. package/build/types-DEtn2Zdb.js +75 -0
  81. package/build/userinfo_controller-YwN26wK-.js +37 -0
  82. package/build/vite.config.d.ts +2 -0
  83. package/package.json +55 -54
  84. package/build/authorize_controller-YUfAy-R2.js +0 -138
  85. package/build/client_service-WTNMqWzY.js +0 -65
  86. package/build/consent_controller-Dprwd1ed.js +0 -85
  87. package/build/oauth_access_token-bsoM5KeU.js +0 -18
  88. package/build/oauth_client-BIoY5jBR.js +0 -24
  89. package/build/oauth_error-CnJ3L8tf.js +0 -94
  90. package/build/revoke_controller-D6isoQCi.js +0 -41
  91. package/build/sesame_manager-Bu4MHqZV.js +0 -4
  92. package/build/sesame_manager-DwDZy5Vy.js +0 -167
  93. package/build/src/grants/authorization_code_grant.d.ts +0 -23
  94. package/build/src/grants/client_credentials_grant.d.ts +0 -23
  95. package/build/src/grants/refresh_token_grant.d.ts +0 -27
  96. package/build/token_controller-DzcrLMyS.js +0 -194
  97. package/build/token_service-fhoA4slP.js +0 -31
package/LICENSE.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # The MIT License
2
2
 
3
- Copyright (c) 2023
3
+ Copyright (c) 2025-present Julien Ripouteau
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
6
 
package/README.md CHANGED
@@ -2,51 +2,106 @@
2
2
 
3
3
  > OAuth 2.1 + OIDC server for AdonisJS
4
4
 
5
- Sésame is an AdonisJS package that turns your application into a full-featured OAuth 2.1 authorization server. It implements the core OAuth 2.1 specification along with OIDC discovery, token introspection, dynamic client registration, and MCP (Model Context Protocol) support.
5
+ Sésame turns your AdonisJS application into a full-featured OAuth 2.1 authorization server. This guide covers:
6
6
 
7
- ## Features
7
+ - Installing and configuring the package
8
+ - Registering OAuth and discovery routes
9
+ - Protecting your API with the OAuth guard and scope checking
10
+ - Managing tokens (refresh, revoke, introspect)
11
+ - Enabling OpenID Connect (OIDC) for `id_token` emission, `/userinfo`, and JWKS
12
+ - Using the Client Credentials grant for machine-to-machine authentication
13
+ - Dynamic client registration and MCP support
8
14
 
9
- - **Authorization Code Grant** with PKCE (S256)
10
- - **Refresh Token Rotation** with replay detection
11
- - **Token Introspection** (RFC 7662) and **Revocation** (RFC 7009)
12
- - **Dynamic Client Registration** (RFC 7591)
13
- - **OIDC Discovery** (`/.well-known/openid-configuration`)
14
- - **OAuth Server Metadata** (RFC 8414)
15
- - **Protected Resource Metadata** (RFC 9728) for MCP servers
16
- - **Type-safe scopes** via module augmentation
17
- - **OAuth guard** for `@adonisjs/auth` with scope-checking middleware
18
- - **Token cleanup** via `sesame:purge` Ace command
15
+ ## Overview
16
+
17
+ Modern applications need a reliable way to delegate authorization. Sésame implements the OAuth 2.1 specification on top of AdonisJS, giving you an authorization code flow with PKCE, refresh token rotation with replay detection, token introspection, revocation, and dynamic client registration out of the box.
18
+
19
+ When you need identity claims on top of authorization, Sésame supports OpenID Connect. You provide an RSA key pair, wire up a user provider, and the server starts issuing signed `id_token` JWTs alongside access tokens.
19
20
 
20
21
  ## Installation
21
22
 
23
+ `@adonisjs/auth` is required, including when you use Kysely without Lucid. If your application does not already have it, run `pnpm add @adonisjs/auth` before installing Sésame.
24
+
22
25
  ```bash
23
26
  node ace add @julr/sesame
24
27
  ```
25
28
 
26
- This will:
27
-
28
- - Publish the configuration file to `config/sesame.ts`
29
- - Publish database migrations (6 tables)
30
- - Register the service provider and commands
31
-
32
- Then run the migrations:
29
+ By default, this publishes `config/sesame.ts`, creates six Lucid migrations, and registers the service provider and commands. Make sure `@adonisjs/lucid` is installed in your application, then run the migrations:
33
30
 
34
31
  ```bash
35
32
  node ace migration:run
36
33
  ```
37
34
 
35
+ ### Kysely installation
36
+
37
+ You can use Sésame in a Kysely application without installing Lucid. The installer publishes a configuration file and a migration, but it does not connect to the database or create tables automatically.
38
+
39
+ ```bash title="Terminal"
40
+ pnpm add kysely
41
+ node ace add @julr/sesame --store=kysely
42
+ ```
43
+
44
+ The generated `config/sesame.ts` selects the Kysely driver. Its `#services/kysely` import is a placeholder. Replace it with the module that exports your application's existing Kysely connection. For example, if that module exports `appDb` from `#services/database`, configure the store like this:
45
+
46
+ ```ts title="config/sesame.ts"
47
+ import env from '#start/env'
48
+ import { defineConfig, stores } from '@julr/sesame'
49
+ import type { InferScopes } from '@julr/sesame/types'
50
+
51
+ const sesameConfig = defineConfig({
52
+ issuer: env.get('APP_URL'),
53
+ store: stores.kysely({
54
+ connection: async () => {
55
+ const { appDb } = await import('#services/database')
56
+ return appDb
57
+ },
58
+ }),
59
+ scopes: {},
60
+ defaultScopes: [],
61
+ grantTypes: ['authorization_code', 'refresh_token'],
62
+ accessTokenTtl: '1h',
63
+ refreshTokenTtl: '30d',
64
+ authorizationCodeTtl: '10m',
65
+ loginPage: '/login',
66
+ consentPage: '/oauth/consent',
67
+ allowDynamicRegistration: false,
68
+ allowPublicRegistration: false,
69
+ })
70
+
71
+ export default sesameConfig
72
+
73
+ declare module '@julr/sesame/types' {
74
+ interface SesameScopes extends InferScopes<typeof sesameConfig> {}
75
+ }
76
+ ```
77
+
78
+ > [!WARNING]
79
+ > The generated config reads `APP_URL`. Your `start/env.ts` must validate that variable, and its value must be the public URL of the OAuth server, not the frontend URL or a `0.0.0.0` bind address. If your application already validates another public API URL, use that variable for `issuer` instead. Without a validated value, `env.get('APP_URL')` can be `undefined` and TypeScript will reject the config.
80
+
81
+ The installer writes the complete migration to `database/kysely_migrations/create_oauth_tables.ts`. Keep that generated file in your application as a schema snapshot. When you already have a Kysely migrator, move it into that migrator's `migrationFolder`, give it a filename that sorts after your existing migrations, and run your usual migration command. For example, an application whose migrator reads `database/migrations` should put the file there. Do not pass it to Lucid's `node ace migration:run`.
82
+
83
+ If Sésame uses its own migration folder, configure a separate [Kysely migrator](https://kysely-org.github.io/kysely-apidoc/classes/migration.Migrator.html) for `database/kysely_migrations` and call `migrateToLatest()` explicitly. Give that migrator unique `migrationTableName` and `migrationLockTableName` values when your application already has another Kysely migrator on the same database. Commit the generated migration so future Sésame upgrades cannot alter a migration you already applied.
84
+
85
+ The Kysely driver supports SQLite, PostgreSQL, and MySQL. The default tests cover SQLite; `pnpm test:sql` runs the PostgreSQL and MySQL integration suite in temporary Docker containers. For a custom adapter backed by one of these databases, pass its `dialect` explicitly to `stores.kysely()`.
86
+
87
+ Sésame configures one `store` at a time. It must be an AdonisJS `ConfigProvider<SesameStore>`; the service provider resolves it with the application before creating the manager. Choose `stores.lucid()`, `stores.kysely()`, or a custom driver. To add a custom driver, implement `SesameStore` from `@julr/sesame/types` and wrap it with `configProvider.create()`. The store interface exposes OAuth operations rather than generic CRUD queries. Its `exchangeAuthorizationCode`, `rotateRefreshToken`, and `issueTokenPair` methods must use real database transactions; the first two return `false` when another request consumed the credential first.
88
+
89
+ Upgrading an existing Lucid application from 0.6.0 requires config and import changes. Follow the [0.6.0 to 0.7.0 migration guide](docs/migration-0.6-to-0.7.md).
90
+
38
91
  ## Configuration
39
92
 
40
- The configuration file lives at `config/sesame.ts`:
93
+ The configuration file lives at `config/sesame.ts`. You define your issuer URL, available scopes, grant types, token lifetimes, and page redirects for the authorization flow.
41
94
 
42
- ```ts
95
+ ```ts title="config/sesame.ts"
43
96
  import env from '#start/env'
44
- import { defineConfig } from '@julr/sesame'
97
+ import { defineConfig, stores } from '@julr/sesame'
45
98
  import type { InferScopes } from '@julr/sesame/types'
46
99
 
47
100
  const sesameConfig = defineConfig({
48
101
  issuer: env.get('APP_URL'),
49
102
 
103
+ store: stores.lucid(),
104
+
50
105
  scopes: {
51
106
  read: 'Read access',
52
107
  write: 'Write access',
@@ -74,13 +129,13 @@ declare module '@julr/sesame/types' {
74
129
  }
75
130
  ```
76
131
 
77
- The `SesameScopes` augmentation gives you type-safe scope names throughout your application.
132
+ The `SesameScopes` module augmentation gives you type-safe scope names throughout your application. When you reference a scope in middleware or guard calls, TypeScript will autocomplete and validate against the scopes you declared.
78
133
 
79
134
  ## Routes
80
135
 
81
- Register OAuth routes from your `start/routes.ts` file:
136
+ You must register OAuth routes from your `start/routes.ts` file. The OAuth endpoints go inside a prefix group, and the discovery endpoints go at the root level so they remain at `/.well-known/...`.
82
137
 
83
- ```ts
138
+ ```ts title="start/routes.ts"
84
139
  import sesame from '@julr/sesame/services/main'
85
140
 
86
141
  // OAuth endpoints under /oauth
@@ -90,31 +145,51 @@ router
90
145
  })
91
146
  .prefix('/oauth')
92
147
 
93
- // Discovery endpoints at the root
94
- sesame.registerWellKnownRoutes()
148
+ // Discovery + JWKS endpoints at root
149
+ sesame.registerDiscoveryRoutes()
95
150
  ```
96
151
 
97
152
  This registers the following endpoints:
98
153
 
99
- | Method | Path | Description |
100
- | ------ | ----------------------------------------- | -------------------------------------- |
101
- | `POST` | `/oauth/token` | Token endpoint |
102
- | `GET` | `/oauth/authorize` | Authorization endpoint |
103
- | `POST` | `/oauth/consent` | Consent submission |
104
- | `POST` | `/oauth/introspect` | Token introspection (RFC 7662) |
105
- | `POST` | `/oauth/revoke` | Token revocation (RFC 7009) |
106
- | `POST` | `/oauth/register` | Dynamic client registration (RFC 7591) |
107
- | `GET` | `/oauth/client-info` | Public client info |
108
- | `GET` | `/.well-known/oauth-authorization-server` | Server metadata (RFC 8414) |
109
- | `GET` | `/.well-known/openid-configuration` | OIDC discovery |
154
+ | Method | Path | Description |
155
+ | ---------- | ----------------------------------------- | ---------------------------------------- |
156
+ | `POST` | `/oauth/token` | Token endpoint (RFC 6749 §3.2) |
157
+ | `GET` | `/oauth/authorize` | Authorization endpoint (RFC 6749 §3.1) |
158
+ | `POST` | `/oauth/consent` | Consent submission |
159
+ | `POST` | `/oauth/introspect` | Token introspection (RFC 7662) |
160
+ | `POST` | `/oauth/revoke` | Token revocation (RFC 7009) |
161
+ | `POST` | `/oauth/register` | Dynamic client registration (RFC 7591) |
162
+ | `GET` | `/oauth/client-info` | Public client information |
163
+ | `GET/POST` | `/oauth/userinfo` | OpenID Connect UserInfo (OIDC Core §5.3) |
164
+ | `GET` | `/.well-known/oauth-authorization-server` | Server metadata (RFC 8414) |
165
+ | `GET` | `/.well-known/openid-configuration` | OIDC discovery |
166
+ | `GET` | `/.well-known/oauth-protected-resource` | Protected resource metadata (RFC 9728) |
167
+ | `GET` | `/jwks` | JSON Web Key Set (RFC 7517) |
168
+
169
+ The JWKS path defaults to `/jwks`. You can customize it:
170
+
171
+ ```ts title="start/routes.ts"
172
+ sesame.registerDiscoveryRoutes({ jwksPath: '/.well-known/jwks.json' })
173
+ ```
174
+
175
+ ## Authorization Code Flow
176
+
177
+ The authorization code flow works in three steps. All clients must use PKCE with S256 (mandatory per OAuth 2.1).
178
+
179
+ 1. The consuming app redirects the user to `GET /oauth/authorize` with `client_id`, `redirect_uri`, `response_type=code`, `scope`, `state`, `code_challenge`, and `code_challenge_method=S256`. If the user is not logged in, they are sent to your `loginPage`. Once authenticated, they see the consent screen (your `consentPage`). If the user has already approved the requested scopes, consent is skipped and the code is issued directly.
180
+
181
+ 2. After the user approves, they are redirected back to the `redirect_uri` with a `code` and `state` parameter. The consuming app exchanges the code at `POST /oauth/token` with `grant_type=authorization_code`, the `code`, `redirect_uri`, client credentials, and the PKCE `code_verifier`. The response contains an `access_token`, `refresh_token` (when the `refresh_token` grant is enabled), `token_type`, `expires_in`, and `scope`. The `iss` parameter is included in all redirect responses per RFC 9207.
182
+
183
+ 3. The consuming app passes the access token as a `Bearer` token in the `Authorization` header when calling your API.
110
184
 
111
185
  ## Authentication Guard
112
186
 
113
- Sésame provides an OAuth guard for `@adonisjs/auth`. Configure it in `config/auth.ts`:
187
+ Sésame provides an OAuth guard for `@adonisjs/auth` that verifies opaque Bearer tokens against the database, checks revocation and expiry, and resolves the user model. Configure it in `config/auth.ts`:
114
188
 
115
- ```ts
116
- import { oauthGuard, oauthUserProvider } from '@julr/sesame/guard'
117
- import User from '#models/user'
189
+ ```ts title="config/auth.ts"
190
+ import { defineConfig } from '@adonisjs/auth'
191
+ import { oauthGuard } from '@julr/sesame/guard'
192
+ import { oauthUserProvider } from '@julr/sesame/guard/lucid'
118
193
 
119
194
  const authConfig = defineConfig({
120
195
  default: 'web',
@@ -127,25 +202,32 @@ const authConfig = defineConfig({
127
202
  })
128
203
  ```
129
204
 
130
- Then use it in your controllers:
205
+ Then use the guard in your controllers. After authentication, you have access to the user, the granted scopes, and the client ID.
206
+
207
+ ```ts title="app/controllers/api_controller.ts"
208
+ import type { HttpContext } from '@adonisjs/core/http'
131
209
 
132
- ```ts
133
210
  export default class ApiController {
134
211
  async index({ auth }: HttpContext) {
135
212
  const guard = auth.use('oauth')
136
213
  await guard.authenticate()
137
214
 
138
215
  const user = auth.user!
139
- const scopes = guard.scopes
216
+ const scopes = guard.scopes // e.g. ['read', 'write']
217
+ const clientId = guard.clientId // e.g. 'my-app-client-id'
218
+
219
+ return { user, scopes, clientId }
140
220
  }
141
221
  }
142
222
  ```
143
223
 
144
- ## Scope Middleware
224
+ ## Scopes
145
225
 
146
- Two named middleware are available for checking scopes on authenticated requests:
226
+ ### Scope Middleware
147
227
 
148
- ```ts
228
+ Two named middleware are available for checking scopes on authenticated requests. Use `scopes` when the client must have **all** listed scopes, and `anyScope` when having **at least one** is sufficient.
229
+
230
+ ```ts title="start/routes.ts"
149
231
  // Requires ALL listed scopes
150
232
  router.get('/admin', [AdminController]).use(middleware.scopes({ scopes: ['admin', 'write'] }))
151
233
 
@@ -153,32 +235,351 @@ router.get('/admin', [AdminController]).use(middleware.scopes({ scopes: ['admin'
153
235
  router.get('/data', [DataController]).use(middleware.anyScope({ scopes: ['read', 'write'] }))
154
236
  ```
155
237
 
156
- ## MCP Support
238
+ Important: these middleware are `TransientToken`-like. If the request carries an OAuth Bearer token, scopes are enforced against that token. If there is no Bearer token but the request is already authenticated through a session/web guard, the middleware lets the request through instead of rejecting on missing OAuth scopes.
157
239
 
158
- For MCP (Model Context Protocol) servers, register per-resource discovery:
240
+ Use these middleware on routes that are allowed to accept either:
241
+
242
+ - a scoped OAuth access token
243
+ - or a first-party session-authenticated user
244
+
245
+ If you want to require OAuth scopes strictly, authenticate with `auth.use('oauth').authenticate()` in your controller or route pipeline and check scopes on that guard explicitly.
246
+
247
+ ### Programmatic Scope Checking
248
+
249
+ You can also check scopes directly in your controller logic using `hasScope()` and `hasAnyScope()` on the guard instance. This is useful when you need conditional behavior based on scopes rather than a hard reject.
250
+
251
+ ```ts title="app/controllers/posts_controller.ts"
252
+ import type { HttpContext } from '@adonisjs/core/http'
253
+
254
+ export default class PostsController {
255
+ async index({ auth }: HttpContext) {
256
+ const guard = auth.use('oauth')
257
+ await guard.authenticate()
258
+
259
+ // Check if the token has a specific scope
260
+ if (guard.hasScope('write')) {
261
+ return { posts: await Post.all(), canEdit: true }
262
+ }
263
+
264
+ return { posts: await Post.all(), canEdit: false }
265
+ }
266
+ }
267
+ ```
268
+
269
+ `hasScope()` requires **all** provided scopes. `hasAnyScope()` requires **at least one**.
270
+
271
+ ## Managing Tokens
272
+
273
+ ### Refreshing tokens
274
+
275
+ The consuming app sends `POST /oauth/token` with `grant_type=refresh_token`, the `refresh_token`, and client credentials to get a new token pair. Sésame uses **refresh token rotation**: every refresh returns a new refresh token and the old one is revoked immediately. If an attacker replays a revoked refresh token, all tokens for that client+user pair are nuked as a security measure. The client can request a narrower set of scopes by passing a `scope` parameter, but cannot request scopes that were not in the original grant.
276
+
277
+ ### Revoking tokens
278
+
279
+ The consuming app can call `POST /oauth/revoke` with the `token`, optional `token_type_hint`, and client credentials. The endpoint always returns HTTP 200, even if the token was not found (to prevent information leakage per RFC 7009). When revoking a refresh token, the associated access token is also revoked automatically.
280
+
281
+ On the server side, you can revoke all tokens for a user at once. This is useful when a user is deleted or deactivated.
159
282
 
160
283
  ```ts
161
- sesame.registerProtectedResource({
162
- resource: '/api/mcp',
163
- scopes: ['read:mcp'],
284
+ import sesame from '@julr/sesame/services/main'
285
+
286
+ await sesame.revokeAllForUser(user.id)
287
+ ```
288
+
289
+ ### Introspecting tokens
290
+
291
+ Resource servers can verify a token's state by calling `POST /oauth/introspect` with the `token`, optional `token_type_hint`, and client credentials. The response is `{ "active": true, "token_type": "Bearer", "client_id": "...", "sub": "...", "scope": "...", ... }` for valid tokens, or `{ "active": false }` for invalid, expired, or revoked tokens. This is useful when a separate service needs to validate tokens without sharing database access.
292
+
293
+ ## OpenID Connect (OIDC)
294
+
295
+ Sésame supports OpenID Connect on top of OAuth 2.1. When OIDC is enabled, the server issues signed `id_token` JWTs alongside access tokens, exposes a `/userinfo` endpoint for retrieving user claims, and publishes a JWKS so relying parties can verify token signatures.
296
+
297
+ OIDC is opt-in. You need two things: an RSA key pair (JWK) for signing ID tokens, and a user provider so the server can resolve user claims.
298
+
299
+ ### Generating a JWK
300
+
301
+ You need an RSA private key in JWK format. The easiest way is to write it directly to your `.env` file:
302
+
303
+ ```bash
304
+ node ace sesame:key --write-env
305
+ ```
306
+
307
+ This generates a JWK and adds (or replaces) `OIDC_JWK` in your `.env` file. Never commit the private key to your repository.
308
+
309
+ You can also output the raw JSON for piping to a secret manager or file:
310
+
311
+ ```bash
312
+ node ace sesame:key --raw > jwk.json
313
+ ```
314
+
315
+ Or run `node ace sesame:key` without flags to see the key with usage instructions.
316
+
317
+ ### Configuration
318
+
319
+ Pass the JWK and a user provider to `defineConfig`. The `oidcProvider` uses the same `oauthUserProvider` helper you configure for the auth guard.
320
+
321
+ ```ts title="config/sesame.ts"
322
+ import env from '#start/env'
323
+ import { defineConfig, stores } from '@julr/sesame'
324
+ import { oauthUserProvider } from '@julr/sesame/guard/lucid'
325
+
326
+ const sesameConfig = defineConfig({
327
+ issuer: env.get('APP_URL'),
328
+
329
+ store: stores.lucid(),
330
+
331
+ scopes: {
332
+ read: 'Read access',
333
+ write: 'Write access',
334
+ },
335
+
336
+ loginPage: '/login',
337
+ consentPage: '/oauth/consent',
338
+
339
+ // OIDC configuration
340
+ jwk: JSON.parse(env.get('OIDC_JWK')),
341
+ oidcProvider: oauthUserProvider({ model: () => import('#models/user') }),
342
+ idTokenTtl: '1h',
164
343
  })
165
344
  ```
166
345
 
167
- This creates a `/.well-known/oauth-protected-resource/api/mcp` endpoint following RFC 9728.
346
+ Both `jwk` and `oidcProvider` must be set for OIDC to be active. If either is missing, the server works as a pure OAuth 2.1 server and OIDC endpoints return 404.
168
347
 
169
- You can also enable public client registration for MCP clients:
348
+ ### User Claims
170
349
 
171
- ```ts
350
+ When the `openid` scope is granted, Sésame calls `getOidcClaims()` on your User model to populate the `id_token` and `/userinfo` response with user-specific claims. If the method is not implemented, only protocol-level claims (`sub`, `iss`, `aud`, `exp`, `iat`) are included.
351
+
352
+ Implement the `OidcSubject` interface and use the `collectOidcClaims` helper for a type-safe, declarative mapping of scopes to claims:
353
+
354
+ ```ts title="app/models/user.ts"
355
+ import { BaseModel, column } from '@adonisjs/lucid/orm'
356
+ import { collectOidcClaims } from '@julr/sesame/types'
357
+ import type { OidcSubject, Scope } from '@julr/sesame/types'
358
+
359
+ export default class User extends BaseModel implements OidcSubject {
360
+ @column({ isPrimary: true })
361
+ declare id: number
362
+
363
+ @column()
364
+ declare fullName: string
365
+
366
+ @column()
367
+ declare email: string
368
+
369
+ /**
370
+ * Return OIDC claims based on the granted scopes.
371
+ * Protocol-managed claims (sub, iss, aud, exp, iat, nonce, at_hash)
372
+ * are filtered out automatically so you cannot accidentally override them.
373
+ */
374
+ getOidcClaims(scopes: Scope[]) {
375
+ return collectOidcClaims(scopes, {
376
+ profile: { name: this.fullName },
377
+ email: { email: this.email },
378
+ })
379
+ }
380
+ }
381
+ ```
382
+
383
+ ### OIDC Scopes
384
+
385
+ Three scopes are OIDC-specific: `openid`, `profile`, and `email`. They are recognized by the server without needing to be declared in your `scopes` config.
386
+
387
+ - `openid` triggers `id_token` emission. The `profile` and `email` scopes are only valid when `openid` is also requested.
388
+ - If a client requests `openid` but OIDC is not configured, the authorization endpoint rejects the request with `invalid_scope`.
389
+
390
+ ### How `id_token` Is Issued
391
+
392
+ When the `openid` scope is present in the authorization code or refresh token exchange, the token response includes an `id_token` field alongside `access_token` and `refresh_token`:
393
+
394
+ ```json
395
+ {
396
+ "access_token": "oat_...",
397
+ "token_type": "Bearer",
398
+ "expires_in": 3600,
399
+ "refresh_token": "ort_...",
400
+ "id_token": "eyJhbGciOiJSUzI1NiIs..."
401
+ }
402
+ ```
403
+
404
+ The `id_token` is a signed JWT containing `iss`, `sub`, `aud`, `iat`, `exp`, `at_hash`, and any claims returned by `getOidcClaims()`. When the authorization request included a `nonce` parameter, it is echoed in the `id_token` payload. On refresh token exchanges, the `nonce` is omitted per OIDC Core §12.2.
405
+
406
+ ### UserInfo Endpoint
407
+
408
+ The `/userinfo` endpoint (GET and POST) returns claims about the authenticated user. It requires a valid access token with the `openid` scope. The token can be passed as a `Bearer` header or as an `access_token` body parameter.
409
+
410
+ ```bash
411
+ curl -H "Authorization: Bearer oat_..." https://auth.example.com/oauth/userinfo
412
+ ```
413
+
414
+ ```json
415
+ {
416
+ "sub": "42",
417
+ "name": "Julien Ripouteau",
418
+ "email": "julien@example.com"
419
+ }
420
+ ```
421
+
422
+ ### JWKS Endpoint
423
+
424
+ The `/jwks` endpoint serves the public key(s) used to sign ID tokens. Relying parties use this to verify `id_token` signatures without needing the private key. The response includes a `Cache-Control` header (`public, max-age=900`) so clients can cache the key set.
425
+
426
+ ## Client Credentials Grant
427
+
428
+ For machine-to-machine (M2M) authentication, enable the `client_credentials` grant. This allows a confidential client to send `POST /oauth/token` with `grant_type=client_credentials`, its credentials (via Basic auth or POST body), and the requested `scope`. No refresh token is issued.
429
+
430
+ ```ts title="config/sesame.ts"
431
+ const sesameConfig = defineConfig({
432
+ // ...
433
+ grantTypes: ['authorization_code', 'refresh_token', 'client_credentials'],
434
+ clientCredentialsAccessTokenTtl: '2h',
435
+ })
436
+ ```
437
+
438
+ User-centric scopes (`openid`, `profile`, `email`, `offline_access`) are rejected for client credentials since they are meaningless in an M2M context. The client must be associated with a user (`userId` on the client record) and must be confidential (not public).
439
+
440
+ ## Dynamic Client Registration
441
+
442
+ Sésame supports RFC 7591 dynamic client registration. Clients send their metadata (`redirect_uris`, `client_name`, `grant_types`, `scope`, `token_endpoint_auth_method`) to `POST /oauth/register` and receive a `client_id` and `client_secret` in return. Set `token_endpoint_auth_method` to `"none"` for public clients (no secret issued). Requested scopes and grant types are validated against your server config.
443
+
444
+ ```ts title="config/sesame.ts"
172
445
  const sesameConfig = defineConfig({
173
446
  // ...
174
447
  allowDynamicRegistration: true,
175
- allowPublicRegistration: true,
448
+ allowPublicRegistration: true, // allows unauthenticated registration
176
449
  })
177
450
  ```
178
451
 
452
+ ## Managing Clients
453
+
454
+ ### Creating clients from the CLI
455
+
456
+ The `sesame:client` Ace command creates a new OAuth client interactively. It prompts for a name, redirect URIs, and client type, then outputs the generated credentials.
457
+
458
+ ```bash
459
+ node ace sesame:client
460
+ ```
461
+
462
+ You can also pass flags to skip the prompts:
463
+
464
+ ```bash
465
+ node ace sesame:client --name "My App" --redirect-uris https://app.example.com/callback
466
+ node ace sesame:client --name "SPA" --public --redirect-uris https://spa.example.com/callback
467
+ node ace sesame:client --name "M2M Service" --grant-types client_credentials --user-id 42
468
+ ```
469
+
470
+ The client secret is displayed once at creation time and cannot be retrieved later (it is stored as a SHA-256 hash).
471
+
472
+ ### Programmatic client management
473
+
474
+ The `SesameManager` exposes methods for managing clients from your application code. This is useful for admin panels, seeding scripts, or any workflow where you need to create and manage clients without the CLI or dynamic registration.
475
+
476
+ ```ts
477
+ import sesame from '@julr/sesame/services/main'
478
+
479
+ // Create a confidential client
480
+ const { client, clientSecret } = await sesame.createClient({
481
+ name: 'Partner App',
482
+ redirectUris: ['https://partner.example.com/callback'],
483
+ scopes: ['read', 'write'],
484
+ grantTypes: ['authorization_code', 'refresh_token'],
485
+ })
486
+
487
+ // Create a public client (no secret)
488
+ const { client: spa } = await sesame.createClient({
489
+ name: 'SPA',
490
+ redirectUris: ['https://spa.example.com/callback'],
491
+ isPublic: true,
492
+ })
493
+ ```
494
+
495
+ `createClient` returns a plain client record and the raw secret. The secret is only available at creation time. Client records returned by the public management methods do not serialize the stored `clientSecret` hash.
496
+
497
+ To find, list, update, or delete clients:
498
+
499
+ ```ts
500
+ // Find by public client_id
501
+ const client = await sesame.findClient('a1b2c3...')
502
+
503
+ // List all clients (optionally filtered by owner)
504
+ const allClients = await sesame.listClients()
505
+ const userClients = await sesame.listClients({ userId: '42' })
506
+
507
+ // Update specific fields
508
+ await sesame.updateClient('a1b2c3...', {
509
+ name: 'New Name',
510
+ redirectUris: ['https://new.example.com/callback'],
511
+ isDisabled: true,
512
+ })
513
+
514
+ // Delete a client and all its tokens, codes, and consents
515
+ await sesame.deleteClient('a1b2c3...')
516
+ ```
517
+
518
+ To rotate a confidential client's secret (e.g. after a suspected leak):
519
+
520
+ ```ts
521
+ const newSecret = await sesame.rotateClientSecret('a1b2c3...')
522
+ // Returns the new raw secret, or null if the client is public or not found
523
+ ```
524
+
525
+ ## MCP Support
526
+
527
+ For MCP (Model Context Protocol) servers, you can register per-resource discovery endpoints following RFC 9728. This tells MCP clients which authorization server protects a given resource.
528
+
529
+ ```ts title="start/routes.ts"
530
+ sesame.registerProtectedResource({
531
+ resource: '/api/mcp',
532
+ scopes: ['read:mcp'],
533
+ })
534
+ ```
535
+
536
+ This creates a `/.well-known/oauth-protected-resource/api/mcp` endpoint. MCP clients that support the latest spec will discover this automatically.
537
+
538
+ MCP clients typically need to self-register, so you will want to enable dynamic client registration with public access (see the [Dynamic Client Registration](#dynamic-client-registration) section above).
539
+
540
+ ## Events
541
+
542
+ The OAuth guard emits events during authentication that you can listen to for logging, analytics, or custom behavior.
543
+
544
+ | Event | When |
545
+ | ------------------------------------- | ---------------------------------------------------------------------- |
546
+ | `oauth_auth:authentication_attempted` | A bearer token has been received and authentication starts |
547
+ | `oauth_auth:authentication_succeeded` | The token is valid and the user has been resolved |
548
+ | `oauth_auth:authentication_failed` | The token is invalid, expired, revoked, or the user cannot be resolved |
549
+
550
+ ```ts title="start/events.ts"
551
+ import emitter from '@adonisjs/core/services/emitter'
552
+
553
+ emitter.on('oauth_auth:authentication_failed', (event) => {
554
+ logger.warn({ guardName: event.guardName, err: event.error }, 'OAuth authentication failed')
555
+ })
556
+ ```
557
+
558
+ ## Testing
559
+
560
+ The OAuth guard implements `authenticateAsClient`, which integrates with Japa's `loginAs` helper. This automatically creates a test OAuth client and access token in the database, so your tests can make authenticated API requests without going through the full authorization flow.
561
+
562
+ ```ts title="tests/functional/api.spec.ts"
563
+ import { test } from '@japa/runner'
564
+ import User from '#models/user'
565
+
566
+ test.group('API', () => {
567
+ test('returns user data for authenticated request', async ({ client }) => {
568
+ const user = await User.find(1)
569
+
570
+ const response = await client.get('/api/me').loginAs(user, 'oauth')
571
+
572
+ response.assertStatus(200)
573
+ response.assertBodyContains({ id: user.id })
574
+ })
575
+ })
576
+ ```
577
+
578
+ The test client is created with `defaultScopes` from your config. The token is scoped to a `__test_client__` OAuth client that gets auto-created on first use.
579
+
179
580
  ## Token Cleanup
180
581
 
181
- Purge expired and revoked tokens with the Ace command:
582
+ Expired and revoked tokens accumulate over time. Purge them with the Ace command:
182
583
 
183
584
  ```bash
184
585
  node ace sesame:purge
@@ -187,22 +588,27 @@ node ace sesame:purge --expired-only
187
588
  node ace sesame:purge --retention-hours=168
188
589
  ```
189
590
 
591
+ The `--retention-hours` flag (default: 168, i.e. 7 days) controls how long expired tokens are kept for audit purposes before deletion.
592
+
190
593
  You can also call it programmatically:
191
594
 
192
595
  ```ts
193
596
  import sesame from '@julr/sesame/services/main'
194
597
 
195
598
  const result = await sesame.purgeTokens({ retentionHours: 168 })
599
+ // => { accessTokens: 42, refreshTokens: 12, authorizationCodes: 3, pendingRequests: 7 }
196
600
  ```
197
601
 
198
602
  ## Security
199
603
 
200
- - Tokens (access, refresh, authorization codes, client secrets) are stored as **SHA-256 hashes** — raw values are never persisted
201
- - PKCE with **S256** is required for public clients
202
- - Refresh tokens use **rotation** — the old token is revoked on each use
203
- - **Replay detection**: if a revoked refresh token is reused, all tokens for that client+user pair are revoked
204
- - Client secret verification uses **timing-safe comparison**
205
- - OAuth errors follow the standard JSON format with proper HTTP status codes and `WWW-Authenticate` headers
604
+ - All tokens (access tokens, refresh tokens, authorization codes, client secrets) are stored as **SHA-256 hashes**. Raw values are never persisted in the database.
605
+ - PKCE with **S256** is mandatory for all clients (OAuth 2.1).
606
+ - Refresh tokens use **rotation**. The old token is revoked immediately on use.
607
+ - **Replay detection**: if a revoked refresh token is presented, all tokens for that client+user pair are revoked to mitigate stolen token reuse.
608
+ - Client secret verification uses **timing-safe comparison**.
609
+ - ID tokens are signed with **RS256** using the configured JWK. The JWKS endpoint only exposes public key components.
610
+ - Protocol-managed claims (`sub`, `iss`, `aud`, `exp`, `iat`, `nonce`, `at_hash`) cannot be overridden by `getOidcClaims()`.
611
+ - OAuth errors follow the standard JSON format with proper HTTP status codes and `WWW-Authenticate` headers.
206
612
 
207
613
  ## License
208
614