@adonis-agora/authkit-client 0.18.0 → 0.18.2

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.
@@ -23,6 +23,8 @@ function readParked(value) {
23
23
  export class AuthkitClientManager {
24
24
  config;
25
25
  #resolverCache;
26
+ /** Mapa de promessas de refresh in-flight por sessionId (dedup thundering herd). */
27
+ #refreshPromises = new Map();
26
28
  constructor(config) {
27
29
  this.config = config;
28
30
  }
@@ -72,15 +74,28 @@ export class AuthkitClientManager {
72
74
  // Sem expiresAt conhecido, não renova proativamente (evita refresh a cada request).
73
75
  if (!tokenSet.expiresAt || tokenSet.expiresAt - now() > REFRESH_SKEW_MS)
74
76
  return;
75
- try {
76
- const next = await refreshTokens({
77
- issuer: this.config.issuer,
78
- clientId: this.config.clientId,
79
- clientSecret: this.config.clientSecret,
80
- refreshToken: tokenSet.refreshToken,
81
- fetchImpl: deps.fetchImpl,
82
- resilience: this.config.resilience,
83
- });
77
+ // Dedup: se já há um refresh in-flight pra esta sessão, espera ele ao invés de
78
+ // disparar outro. O refresh token ROTACIONA — N requests concorrentes chegando
79
+ // juntos fariam N refreshes + N-1 falhas com refresh token já inválido.
80
+ const sessionId = session.sessionId;
81
+ if (sessionId) {
82
+ const pending = this.#refreshPromises.get(sessionId);
83
+ if (pending) {
84
+ await pending.catch(() => { });
85
+ return;
86
+ }
87
+ }
88
+ // Promise que SEMPRE resolve — o catch silencioso mantém o TokenSet original
89
+ // em caso de falha, igual ao comportamento do maybeRefresh original.
90
+ const promise = refreshTokens({
91
+ issuer: this.config.issuer,
92
+ clientId: this.config.clientId,
93
+ clientSecret: this.config.clientSecret,
94
+ refreshToken: tokenSet.refreshToken,
95
+ fetchImpl: deps.fetchImpl,
96
+ resilience: this.config.resilience,
97
+ })
98
+ .then((next) => {
84
99
  session.put(this.config.sessionKey, {
85
100
  idToken: next.idToken || tokenSet.idToken,
86
101
  accessToken: next.accessToken,
@@ -88,10 +103,15 @@ export class AuthkitClientManager {
88
103
  expiresAt: next.expiresAt,
89
104
  impersonationBinding: tokenSet.impersonationBinding,
90
105
  });
91
- }
92
- catch {
106
+ })
107
+ .catch(() => {
93
108
  // Renovação falhou — deixa o TokenSet como está; o resolver decide a sessão.
94
- }
109
+ });
110
+ if (sessionId)
111
+ this.#refreshPromises.set(sessionId, promise);
112
+ await promise;
113
+ if (sessionId)
114
+ this.#refreshPromises.delete(sessionId);
95
115
  }
96
116
  /**
97
117
  * Recebe o POST `application/x-www-form-urlencoded` de OIDC Back-Channel Logout. Lê o
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adonis-agora/authkit-client",
3
- "version": "0.18.0",
3
+ "version": "0.18.2",
4
4
  "description": "AdonisJS OIDC/OAuth2 relying-party (client) toolkit: session-based authentication against an OpenID Connect identity provider, with JWT/PAT user resolvers and OpenTelemetry metrics.",
5
5
  "license": "MIT",
6
6
  "author": "dudousxd",
@@ -21,7 +21,8 @@
21
21
  "authentication",
22
22
  "authkit",
23
23
  "relying-party",
24
- "jwt"
24
+ "jwt",
25
+ "tanstack-intent"
25
26
  ],
26
27
  "publishConfig": {
27
28
  "access": "public"
@@ -31,7 +32,8 @@
31
32
  "types": "./build/index.d.ts",
32
33
  "files": [
33
34
  "build",
34
- "stubs"
35
+ "stubs",
36
+ "skills"
35
37
  ],
36
38
  "exports": {
37
39
  ".": "./build/index.js",
@@ -90,10 +92,11 @@
90
92
  "@japa/assert": "4.2.0",
91
93
  "@japa/runner": "5.3.0",
92
94
  "@poppinss/ts-exec": "1.4.4",
95
+ "@tanstack/intent": "^0.3.2",
93
96
  "ioredis-mock": "8.13.1",
94
97
  "@types/node": "25.9.1",
95
98
  "typescript": "6.0.3",
96
- "@adonis-agora/authkit-server": "0.59.0"
99
+ "@adonis-agora/authkit-server": "0.61.3"
97
100
  },
98
101
  "scripts": {
99
102
  "build": "tsc && node -e \"require('node:fs').cpSync('stubs','build/stubs',{recursive:true,filter:(s)=>!s.endsWith('.ts')})\"",
@@ -0,0 +1,242 @@
1
+ ---
2
+ name: authkit-rp-client
3
+ description: >-
4
+ Integrate an AdonisJS app as an OpenID Connect relying party with
5
+ @adonis-agora/authkit-client. Covers defineConfig({ issuer, clientId, redirectUri,
6
+ resolver }), resolvers.jwt({ tokenSource }) / resolvers.pat / resolvers.opaque,
7
+ resolveUser topologies (identityToUser, createUserinfoResolver, local Lucid model),
8
+ the authkit_middleware + @adonisjs/session ordering rule, ctx.auth
9
+ (getIdentity/getUser/hasGlobalRole), registerOidcClient(router, { prefix, redirects,
10
+ afterLogin, loginMiddleware }), backchannelLogout: { store } +
11
+ BackchannelRevocationMiddleware, AUTHKIT_REDIRECT_URI, and why app-role gating
12
+ belongs to @adonis-agora/authz instead. Use when wiring a client app to the IdP,
13
+ fixing null identity, choosing a user-resolution topology, or adding back-channel
14
+ logout.
15
+ license: MIT
16
+ metadata:
17
+ type: core
18
+ library: "@adonis-agora/authkit-client"
19
+ library_version: "0.18.0"
20
+ framework: adonisjs
21
+ sources:
22
+ - "DavideCarvalho/adonis-authkit:packages/authkit-client/README.md"
23
+ - "DavideCarvalho/adonis-authkit:packages/authkit-client/src/define_config.ts"
24
+ - "DavideCarvalho/adonis-authkit:packages/authkit-client/src/resolvers/factory.ts"
25
+ - "DavideCarvalho/adonis-authkit:packages/authkit-client/src/register_oidc_client.ts"
26
+ ---
27
+
28
+ # OIDC relying-party (client) integration
29
+
30
+ `@adonis-agora/authkit-client` makes an AdonisJS app consume an OIDC provider (the
31
+ AuthKit IdP or any other) with session-based auth: PKCE authorization-code login,
32
+ locally-validated ID tokens (JWKS), typed `ctx.auth` per request. `configure`
33
+ publishes `config/authkit_client.ts`, `OidcSessionController` (login/callback/logout)
34
+ and registers the provider + middleware.
35
+
36
+ ## Setup
37
+
38
+ ```bash
39
+ node ace add @adonis-agora/authkit-client
40
+ ```
41
+
42
+ ```ts
43
+ // config/authkit_client.ts
44
+ import env from '#start/env'
45
+ import { defineConfig, resolvers } from '@adonis-agora/authkit-client'
46
+
47
+ const authkitClientConfig = defineConfig({
48
+ issuer: env.get('AUTHKIT_ISSUER'),
49
+ clientId: env.get('AUTHKIT_CLIENT_ID'),
50
+ clientSecret: env.get('AUTHKIT_CLIENT_SECRET'),
51
+ redirectUri: env.get('AUTHKIT_REDIRECT_URI'), // must point at the callback route
52
+ resolver: resolvers.jwt({ tokenSource: 'session' }),
53
+ })
54
+
55
+ export default authkitClientConfig
56
+ ```
57
+
58
+ ```ts
59
+ // start/routes.ts
60
+ import router from '@adonisjs/core/services/router'
61
+ import OidcSessionController from '#controllers/oidc_session_controller'
62
+
63
+ router.get('/auth/login', [OidcSessionController, 'login'])
64
+ router.get('/auth/callback', [OidcSessionController, 'callback'])
65
+ router.post('/auth/logout', [OidcSessionController, 'logout'])
66
+ ```
67
+
68
+ Source: `packages/authkit-client/README.md`, `stubs/config/authkit_client.stub`.
69
+
70
+ ## Core patterns
71
+
72
+ ### Pattern 1 — `ctx.auth` per request
73
+
74
+ The `authkit_middleware` populates `ctx.auth`; identity comes from the validated
75
+ token, the user from your `resolveUser`:
76
+
77
+ ```ts
78
+ router.get('/me', async ({ auth }) => {
79
+ const identity = await auth.getIdentity() // validated OIDC claims, or null
80
+ const user = await auth.getUser() // app model via resolveUser
81
+ const isAdmin = auth.hasGlobalRole('ADMIN') // sync — reads token claims
82
+ return { identity, user, isAdmin }
83
+ })
84
+ ```
85
+
86
+ `getIdentity()` resolves once per request and memoizes; `authenticate()` throws when
87
+ anonymous; `check()` returns a boolean. `identity` (getter) is only populated after
88
+ the first async call — prefer `getIdentity()`.
89
+
90
+ Source: `packages/authkit-client/README.md` § Uso por request,
91
+ `src/authenticator.ts` (`Authenticator`).
92
+
93
+ ### Pattern 2 — `resolveUser` per database topology
94
+
95
+ Identity always comes from the token; what varies is how the app model is fetched:
96
+
97
+ ```ts
98
+ // Same DB, cross-schema FK: map to a local Lucid model
99
+ resolveUser: async (identity) => {
100
+ return AppUser.query().where('authUserId', identity.userId).firstOrFail()
101
+ }
102
+
103
+ // Separate DBs: the IdP emits a fat ID token — claims-only, zero lookups
104
+ import { identityToUser } from '@adonis-agora/authkit-client'
105
+ resolveUser: identityToUser // { id, email, name?, avatarUrl?, globalRoles }
106
+
107
+ // Separate DBs + data beyond the claims: userinfo resolver against ${issuer}/me
108
+ import { createUserinfoResolver } from '@adonis-agora/authkit-client'
109
+ resolveUser: createUserinfoResolver({ issuer: env.get('AUTHKIT_ISSUER') })
110
+ ```
111
+
112
+ Source: `packages/authkit-client/README.md` § Topologias de banco,
113
+ `src/user_resolvers.ts`.
114
+
115
+ ### Pattern 3 — one-liner routes with `registerOidcClient`
116
+
117
+ Instead of hand-writing the controller trio, register PKCE + state, code exchange,
118
+ RP-initiated logout and role-based redirects in one call:
119
+
120
+ ```ts
121
+ import router from '@adonisjs/core/services/router'
122
+ import middleware from '#start/kernel'
123
+ import { registerOidcClient } from '@adonis-agora/authkit-client'
124
+
125
+ registerOidcClient(router, {
126
+ loginMiddleware: middleware.guest(),
127
+ redirects: { byGlobalRole: { ADMIN: '/admin' }, default: '/' },
128
+ // prefix defaults to /auth → /auth/login, /auth/callback, /auth/logout
129
+ // backchannelLogout defaults to true → also registers POST /auth/backchannel-logout
130
+ })
131
+ ```
132
+
133
+ Source: `src/register_oidc_client.ts` (`RegisterOidcClientOptions` + doc example).
134
+
135
+ ### Pattern 4 — back-channel logout for cookie sessions
136
+
137
+ Pass a store and the kit derives the callback + enforcement middleware:
138
+
139
+ ```ts
140
+ import { defineConfig, resolvers, lucidRevocationStore } from '@adonis-agora/authkit-client'
141
+
142
+ defineConfig({
143
+ issuer: env.get('AUTHKIT_ISSUER'),
144
+ clientId: env.get('AUTHKIT_CLIENT_ID'),
145
+ redirectUri: env.get('AUTHKIT_REDIRECT_URI'),
146
+ resolver: resolvers.jwt(),
147
+ backchannelLogout: { store: lucidRevocationStore() },
148
+ })
149
+ ```
150
+
151
+ Then apply `BackchannelRevocationMiddleware` (from
152
+ `@adonis-agora/authkit-client/backchannel_revocation_middleware`) so revoked
153
+ sessions are rejected per request. Custom needs: `onBackchannelLogout` receives the
154
+ validated `{ sid, sub }` (store runs first, then the hook).
155
+
156
+ Source: `src/define_config.ts` (`BackchannelLogoutInput`), README.
157
+
158
+ ## Common mistakes
159
+
160
+ ### HIGH — Session middleware running after `authkit_middleware`
161
+
162
+ ```ts
163
+ // Wrong — resolver reads an empty session; every request looks anonymous
164
+ router.use([middleware.authkit(), middleware.session()])
165
+ ```
166
+
167
+ ```ts
168
+ // Correct — @adonisjs/session MUST run BEFORE the authkit middleware
169
+ router.use([middleware.session(), middleware.authkit()])
170
+ ```
171
+
172
+ With `resolvers.jwt({ tokenSource: 'session' })` (the default) the resolver reads
173
+ the token set from the `@adonisjs/session` store; if session middleware has not run
174
+ yet the token is never found and `getIdentity()` silently returns `null` — no error,
175
+ just a logged-out app.
176
+
177
+ Source: `packages/authkit-client/README.md` § Ordem de middleware (importante).
178
+
179
+ ### HIGH — Putting app-role authorization in the client kit
180
+
181
+ ```ts
182
+ // Wrong — expecting authkit to resolve app roles/permissions
183
+ if (auth.hasGlobalRole('ADMIN')) { /* app-role check? */ }
184
+ const can = await authkit.checkPermission(user, 'posts.edit') // does not exist
185
+ ```
186
+
187
+ ```ts
188
+ // Correct — AuthKit authenticates + exposes token globalRoles; authz decides access
189
+ if (auth.hasGlobalRole('ADMIN')) { /* global role from the token claims */ }
190
+ // App roles/permissions: configure resolveRoles in @adonis-agora/authz
191
+ // and enforce with authz/Bouncer route guards, not authkit_middleware.
192
+ ```
193
+
194
+ AuthKit only authenticates and knows the `globalRoles` claim; app roles (domain
195
+ tables) and permissions were deliberately moved out to `@adonis-agora/authz`.
196
+ The `authkit_middleware` only enforces login.
197
+
198
+ Source: `packages/authkit-client/README.md` callout ("Autorização por app-role saiu
199
+ do AuthKit"), `stubs/config/authkit_client.stub` comment.
200
+
201
+ ### MEDIUM — `resolvers.opaque` without a confidential client
202
+
203
+ ```ts
204
+ // Wrong — public client: throws at resolver creation
205
+ resolver: resolvers.opaque({ introspectionUrl: '...' })
206
+ ```
207
+
208
+ ```ts
209
+ // Correct — opaque introspection requires the client secret
210
+ resolver: resolvers.opaque({
211
+ tokenSource: 'bearer',
212
+ cacheTtlMs: 30_000, // trade immediacy for fewer round-trips
213
+ })
214
+ ```
215
+
216
+ The opaque resolver introspects with `clientId`/`clientSecret` and the factory
217
+ throws `resolvers.opaque requer um client confidencial (clientSecret)` when the
218
+ config lacks a secret — a public client cannot authenticate introspection.
219
+
220
+ Source: `src/resolvers/factory.ts` (`resolvers.opaque` throw), README.
221
+
222
+ ### MEDIUM — Bearer resolver in a browser session app (or vice versa)
223
+
224
+ ```ts
225
+ // Wrong — SPA/API app still using the default session source
226
+ resolver: resolvers.jwt() // tokenSource defaults to 'session'
227
+ ```
228
+
229
+ ```ts
230
+ // Correct — pick the source matching the transport
231
+ resolver: resolvers.jwt({ tokenSource: 'bearer' }) // reads Authorization header
232
+ ```
233
+
234
+ `tokenSource` decides where the ID token is read from: `'session'` (default, cookie
235
+ apps) vs `'bearer'` (`Authorization` header for SPA/API callers). The mismatched
236
+ source yields a silent `null` identity, not an error.
237
+
238
+ Source: `packages/authkit-client/README.md` § Resolução de sessão,
239
+ `src/resolvers/factory.ts` (`JwtResolverFactoryConfig`).
240
+
241
+ See also: `authkit-idp-setup/SKILL.md` — the IdP side that issues these tokens, and
242
+ `authkit-react-auth/SKILL.md` — how the identity reaches the frontend.