@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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|