@adonis-agora/authkit-client 0.20.0 → 0.20.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.
- package/build/src/define_config.d.ts +3 -0
- package/build/src/discovery.d.ts +3 -1
- package/build/src/discovery.js +37 -8
- package/build/src/lucid_mirror.d.ts +6 -2
- package/build/src/lucid_mirror.js +16 -2
- package/build/src/resolvers/factory.d.ts +2 -1
- package/build/src/resolvers/opaque_resolver.d.ts +3 -1
- package/build/src/resolvers/opaque_resolver.js +29 -3
- package/package.json +10 -10
- package/skills/authkit-rp-client/SKILL.md +3 -0
|
@@ -64,6 +64,9 @@ export interface ClientConfigInput {
|
|
|
64
64
|
* Discovery runs lazily, on first use, so the app boots while the IdP is down.
|
|
65
65
|
* A failure throws `OidcDiscoveryError` and is retried on the next request; a
|
|
66
66
|
* previously discovered document keeps being served while refetches fail.
|
|
67
|
+
* Concurrent callers share one fetch per issuer; once `cacheTtlMs` (default
|
|
68
|
+
* 15 min) runs out, the cached document is served while one background refetch
|
|
69
|
+
* replaces it. Each request attempt is aborted after 10 s.
|
|
67
70
|
*
|
|
68
71
|
* `false` skips the network and uses `endpoints` on top of the authkit-server
|
|
69
72
|
* convention (`/auth`, `/token`, `/jwks`, `/session/end`).
|
package/build/src/discovery.d.ts
CHANGED
|
@@ -51,7 +51,7 @@ export declare class OidcDiscoveryError extends Error {
|
|
|
51
51
|
export interface DiscoverOptions {
|
|
52
52
|
/** Manual overrides — win over the discovery document, field by field. */
|
|
53
53
|
overrides?: Partial<OidcEndpoints>;
|
|
54
|
-
/** Per-issuer cache TTL. Default: 15 min. */
|
|
54
|
+
/** Per-issuer cache TTL; past it the document is served stale while one background refetch runs. Default: 15 min. */
|
|
55
55
|
cacheTtlMs?: number;
|
|
56
56
|
fetchImpl?: typeof fetch;
|
|
57
57
|
/**
|
|
@@ -77,6 +77,8 @@ export interface DiscoverOptions {
|
|
|
77
77
|
* Resolves the IdP endpoints via `/.well-known/openid-configuration`.
|
|
78
78
|
*
|
|
79
79
|
* - Successful documents are cached per issuer (TTL 15 min) — safe to call per request.
|
|
80
|
+
* Once the TTL runs out the cached document keeps being served while one
|
|
81
|
+
* background refetch replaces it; concurrent first calls share one fetch.
|
|
80
82
|
* - The document's `issuer` must match the configured one (OIDC Discovery §4.3;
|
|
81
83
|
* a trailing-slash difference is tolerated and the document's exact value is
|
|
82
84
|
* returned in `issuer`).
|
package/build/src/discovery.js
CHANGED
|
@@ -57,11 +57,17 @@ export class OidcDiscoveryError extends Error {
|
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
const DEFAULT_TTL_MS = 15 * 60_000;
|
|
60
|
+
/** Each discovery request is aborted after this long. */
|
|
61
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
60
62
|
const cache = new Map();
|
|
63
|
+
/** One fetch per issuer at a time; concurrent callers share it. */
|
|
64
|
+
const inflight = new Map();
|
|
61
65
|
/**
|
|
62
66
|
* Resolves the IdP endpoints via `/.well-known/openid-configuration`.
|
|
63
67
|
*
|
|
64
68
|
* - Successful documents are cached per issuer (TTL 15 min) — safe to call per request.
|
|
69
|
+
* Once the TTL runs out the cached document keeps being served while one
|
|
70
|
+
* background refetch replaces it; concurrent first calls share one fetch.
|
|
65
71
|
* - The document's `issuer` must match the configured one (OIDC Discovery §4.3;
|
|
66
72
|
* a trailing-slash difference is tolerated and the document's exact value is
|
|
67
73
|
* returned in `issuer`).
|
|
@@ -73,20 +79,20 @@ export async function discoverEndpoints(issuer, options = {}) {
|
|
|
73
79
|
const onError = options.onError ?? 'fallback';
|
|
74
80
|
const cached = cache.get(base);
|
|
75
81
|
let doc;
|
|
76
|
-
if (cached
|
|
82
|
+
if (cached) {
|
|
77
83
|
doc = cached.doc;
|
|
84
|
+
// Stale-while-revalidate (and stale-while-error): an IdP blip must not break a
|
|
85
|
+
// client that already knows the endpoints. A failed refetch writes nothing, so
|
|
86
|
+
// the next call retries.
|
|
87
|
+
if (Date.now() - cached.fetchedAt >= ttl)
|
|
88
|
+
refresh(base, options).catch(() => { });
|
|
78
89
|
}
|
|
79
90
|
else {
|
|
80
91
|
try {
|
|
81
|
-
doc = await
|
|
82
|
-
cache.set(base, { doc, fetchedAt: Date.now() });
|
|
92
|
+
doc = await refresh(base, options);
|
|
83
93
|
}
|
|
84
94
|
catch (error) {
|
|
85
|
-
|
|
86
|
-
// the endpoints. Nothing is written, so the next call retries.
|
|
87
|
-
if (cached)
|
|
88
|
-
doc = cached.doc;
|
|
89
|
-
else if (onError === 'throw')
|
|
95
|
+
if (onError === 'throw')
|
|
90
96
|
throw error;
|
|
91
97
|
}
|
|
92
98
|
}
|
|
@@ -99,6 +105,28 @@ export async function discoverEndpoints(issuer, options = {}) {
|
|
|
99
105
|
assertRequired(base, endpoints);
|
|
100
106
|
return endpoints;
|
|
101
107
|
}
|
|
108
|
+
function refresh(base, options) {
|
|
109
|
+
let pending = inflight.get(base);
|
|
110
|
+
if (!pending) {
|
|
111
|
+
pending = fetchDiscovery(base, withTimeout(options.fetchImpl ?? fetch), options.resilience)
|
|
112
|
+
.then((doc) => {
|
|
113
|
+
cache.set(base, { doc, fetchedAt: Date.now() });
|
|
114
|
+
return doc;
|
|
115
|
+
})
|
|
116
|
+
.finally(() => inflight.delete(base));
|
|
117
|
+
inflight.set(base, pending);
|
|
118
|
+
}
|
|
119
|
+
return pending;
|
|
120
|
+
}
|
|
121
|
+
/** A fresh timeout per attempt, so a retrying resilience policy is not cut short. */
|
|
122
|
+
function withTimeout(fetchImpl) {
|
|
123
|
+
return ((input, init) => fetchImpl(input, {
|
|
124
|
+
...init,
|
|
125
|
+
signal: init?.signal
|
|
126
|
+
? AbortSignal.any([init.signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)])
|
|
127
|
+
: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
128
|
+
}));
|
|
129
|
+
}
|
|
102
130
|
async function fetchDiscovery(base, fetchImpl, resilience) {
|
|
103
131
|
const url = `${base}/.well-known/openid-configuration`;
|
|
104
132
|
const fail = (message, cause) => {
|
|
@@ -149,4 +177,5 @@ function assertRequired(base, endpoints) {
|
|
|
149
177
|
/** Clears the discovery cache (tests). */
|
|
150
178
|
export function __clearDiscoveryCacheForTests() {
|
|
151
179
|
cache.clear();
|
|
180
|
+
inflight.clear();
|
|
152
181
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { Identity } from '@adonis-agora/authkit-core';
|
|
2
|
-
/** Model Lucid mínimo que o mirror precisa
|
|
2
|
+
/** Model Lucid mínimo que o mirror precisa. */
|
|
3
3
|
interface LucidModelLike {
|
|
4
4
|
updateOrCreate(search: Record<string, unknown>, payload: Record<string, unknown>): Promise<any>;
|
|
5
|
+
/** Leitura sem transação nem lock, usada quando o espelho já está em dia. */
|
|
6
|
+
findBy?(key: string, value: unknown): Promise<any>;
|
|
5
7
|
}
|
|
6
8
|
export interface LucidMirrorOptions {
|
|
7
9
|
/**
|
|
@@ -22,7 +24,9 @@ export interface LucidMirrorOptions {
|
|
|
22
24
|
/**
|
|
23
25
|
* Factory do padrão "espelho local" de `resolveUser`: faz `updateOrCreate` do usuário
|
|
24
26
|
* de domínio a partir das claims do id_token, opcionalmente pré-carrega relações e
|
|
25
|
-
* injeta os papéis globais.
|
|
27
|
+
* injeta os papéis globais. Quando a linha já tem os valores das claims (o caso de
|
|
28
|
+
* todo request depois do login), lê com `findBy` em vez de abrir a transação com
|
|
29
|
+
* `SELECT ... FOR UPDATE` do `updateOrCreate`. Substitui o `resolveUser` que cada app reescrevia.
|
|
26
30
|
*
|
|
27
31
|
* ```ts
|
|
28
32
|
* resolveUser: lucidMirror(AppUser, {
|
|
@@ -1,7 +1,17 @@
|
|
|
1
|
+
/** O usuário já tem exatamente esses valores? Valores não primitivos contam como diferentes. */
|
|
2
|
+
function alreadyMirrored(user, payload) {
|
|
3
|
+
return Object.entries(payload).every(([key, value]) => {
|
|
4
|
+
if (value !== null && typeof value === 'object')
|
|
5
|
+
return false;
|
|
6
|
+
return Object.is(user[key], value);
|
|
7
|
+
});
|
|
8
|
+
}
|
|
1
9
|
/**
|
|
2
10
|
* Factory do padrão "espelho local" de `resolveUser`: faz `updateOrCreate` do usuário
|
|
3
11
|
* de domínio a partir das claims do id_token, opcionalmente pré-carrega relações e
|
|
4
|
-
* injeta os papéis globais.
|
|
12
|
+
* injeta os papéis globais. Quando a linha já tem os valores das claims (o caso de
|
|
13
|
+
* todo request depois do login), lê com `findBy` em vez de abrir a transação com
|
|
14
|
+
* `SELECT ... FOR UPDATE` do `updateOrCreate`. Substitui o `resolveUser` que cada app reescrevia.
|
|
5
15
|
*
|
|
6
16
|
* ```ts
|
|
7
17
|
* resolveUser: lucidMirror(AppUser, {
|
|
@@ -17,7 +27,11 @@ export function lucidMirror(Model, options = {}) {
|
|
|
17
27
|
const idColumn = options.idColumn ?? 'id';
|
|
18
28
|
return async (identity) => {
|
|
19
29
|
const synced = options.sync ? options.sync(identity) : { email: identity.email };
|
|
20
|
-
const
|
|
30
|
+
const payload = { [idColumn]: identity.userId, ...synced };
|
|
31
|
+
let user = Model.findBy ? await Model.findBy(idColumn, identity.userId) : null;
|
|
32
|
+
if (!user || !alreadyMirrored(user, payload)) {
|
|
33
|
+
user = await Model.updateOrCreate({ [idColumn]: identity.userId }, payload);
|
|
34
|
+
}
|
|
21
35
|
if (options.preload) {
|
|
22
36
|
for (const relation of options.preload) {
|
|
23
37
|
await user.load(relation);
|
|
@@ -20,7 +20,8 @@ export interface OpaqueResolverFactoryConfig {
|
|
|
20
20
|
introspectionUrl?: string;
|
|
21
21
|
/**
|
|
22
22
|
* TTL (ms) do cache de respostas `active:true`. Default 0 (revogação imediata).
|
|
23
|
-
* Suba para reduzir round-trips ao custo de imediatismo.
|
|
23
|
+
* Suba para reduzir round-trips ao custo de imediatismo. Nunca além do `exp` do
|
|
24
|
+
* token; no máximo 10.000 tokens por processo.
|
|
24
25
|
*/
|
|
25
26
|
cacheTtlMs?: number;
|
|
26
27
|
}
|
|
@@ -24,7 +24,9 @@ export interface OpaqueResolverConfig {
|
|
|
24
24
|
/**
|
|
25
25
|
* TTL (ms) do cache em memória de respostas `active:true`, para não introspectar
|
|
26
26
|
* a cada request. Default 0 (sem cache → revogação propaga imediatamente). Suba
|
|
27
|
-
* para trocar imediatismo por menos round-trips.
|
|
27
|
+
* para trocar imediatismo por menos round-trips. Uma entrada vence no que vier
|
|
28
|
+
* primeiro entre o TTL e o `exp` do token; o cache guarda no máximo 10.000 tokens
|
|
29
|
+
* (expirados saem primeiro, depois o mais antigo).
|
|
28
30
|
*/
|
|
29
31
|
cacheTtlMs?: number;
|
|
30
32
|
fetchImpl?: FetchImpl;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { getTokenFromSource } from '../token_source.js';
|
|
2
2
|
import { buildIdentityFromClaims, introspectToken } from './identity.js';
|
|
3
|
+
/** Teto de tokens no cache; o mais antigo sai primeiro. */
|
|
4
|
+
const MAX_CACHE_ENTRIES = 10_000;
|
|
3
5
|
/**
|
|
4
6
|
* Resolver de access token OPACO via introspection padrão (RFC 7662) a cada request.
|
|
5
7
|
*
|
|
@@ -29,9 +31,10 @@ export class OpaqueResolver {
|
|
|
29
31
|
const ttl = this.config.cacheTtlMs ?? 0;
|
|
30
32
|
if (ttl > 0) {
|
|
31
33
|
const hit = this.#cache.get(token);
|
|
32
|
-
|
|
33
|
-
if (hit && this.#now() - hit.storedAt < ttl)
|
|
34
|
+
if (hit && this.#now() < hit.expiresAt)
|
|
34
35
|
return hit.identity;
|
|
36
|
+
if (hit)
|
|
37
|
+
this.#cache.delete(token);
|
|
35
38
|
}
|
|
36
39
|
const introspectionUrl = await this.config.introspectionUrl();
|
|
37
40
|
if (!introspectionUrl)
|
|
@@ -44,9 +47,32 @@ export class OpaqueResolver {
|
|
|
44
47
|
}
|
|
45
48
|
const identity = buildIdentityFromClaims(data, this.config.globalRolesClaim);
|
|
46
49
|
if (ttl > 0)
|
|
47
|
-
this.#
|
|
50
|
+
this.#store(token, identity, ttl, data.exp);
|
|
48
51
|
return identity;
|
|
49
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Guarda a identidade por `ttl`, nunca além do `exp` do token: um token que
|
|
55
|
+
* expira antes do TTL deixa de resolver quando expira, não quando o cache vence.
|
|
56
|
+
*/
|
|
57
|
+
#store(token, identity, ttl, exp) {
|
|
58
|
+
const lifetime = typeof exp === 'number' ? Math.min(ttl, exp * 1000 - Date.now()) : ttl;
|
|
59
|
+
if (lifetime <= 0)
|
|
60
|
+
return;
|
|
61
|
+
const now = this.#now();
|
|
62
|
+
if (this.#cache.size >= MAX_CACHE_ENTRIES) {
|
|
63
|
+
for (const [key, entry] of this.#cache) {
|
|
64
|
+
if (entry.expiresAt <= now)
|
|
65
|
+
this.#cache.delete(key);
|
|
66
|
+
}
|
|
67
|
+
for (const key of this.#cache.keys()) {
|
|
68
|
+
if (this.#cache.size < MAX_CACHE_ENTRIES)
|
|
69
|
+
break;
|
|
70
|
+
this.#cache.delete(key);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
this.#cache.delete(token);
|
|
74
|
+
this.#cache.set(token, { identity, expiresAt: now + lifetime });
|
|
75
|
+
}
|
|
50
76
|
/** Relógio monotônico de processo (evita Date.* direto; testável). */
|
|
51
77
|
#now() {
|
|
52
78
|
return Math.trunc(performance.now());
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adonis-agora/authkit-client",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.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",
|
|
@@ -83,21 +83,21 @@
|
|
|
83
83
|
},
|
|
84
84
|
"dependencies": {
|
|
85
85
|
"@adonis-agora/authkit-core": "0.10.0",
|
|
86
|
-
"jose": "6.2.
|
|
86
|
+
"jose": "6.2.13"
|
|
87
87
|
},
|
|
88
88
|
"devDependencies": {
|
|
89
|
-
"@adonis-agora/authkit-server": "0.
|
|
90
|
-
"@adonis-agora/resilience": "0.
|
|
89
|
+
"@adonis-agora/authkit-server": "0.82.0",
|
|
90
|
+
"@adonis-agora/resilience": "0.4.0",
|
|
91
91
|
"@adonisjs/auth": "10.1.0",
|
|
92
|
-
"@adonisjs/core": "7.
|
|
92
|
+
"@adonisjs/core": "7.6.0",
|
|
93
93
|
"@adonisjs/lucid": "22.4.2",
|
|
94
|
-
"@adonisjs/session": "8.
|
|
94
|
+
"@adonisjs/session": "8.3.0",
|
|
95
95
|
"@adonisjs/tsconfig": "2.0.0",
|
|
96
96
|
"@japa/assert": "4.2.0",
|
|
97
|
-
"@japa/runner": "5.
|
|
98
|
-
"@poppinss/ts-exec": "1.
|
|
99
|
-
"@tanstack/intent": "0.
|
|
100
|
-
"@types/node": "26.5
|
|
97
|
+
"@japa/runner": "5.4.0",
|
|
98
|
+
"@poppinss/ts-exec": "1.5.1",
|
|
99
|
+
"@tanstack/intent": "0.5.6",
|
|
100
|
+
"@types/node": "26.6.5",
|
|
101
101
|
"ioredis-mock": "8.13.1",
|
|
102
102
|
"tempura": "0.4.1",
|
|
103
103
|
"testcontainers": "12.2.1",
|
|
@@ -222,6 +222,9 @@ The opaque resolver introspects with `clientId`/`clientSecret` and the factory
|
|
|
222
222
|
throws `resolvers.opaque requer um client confidencial (clientSecret)` when the
|
|
223
223
|
config lacks a secret — a public client cannot authenticate introspection.
|
|
224
224
|
|
|
225
|
+
With `cacheTtlMs`, a cached identity expires at the earlier of the TTL and the
|
|
226
|
+
token's `exp`, and the cache holds at most 10,000 tokens per resolver.
|
|
227
|
+
|
|
225
228
|
Source: `src/resolvers/factory.ts` (`resolvers.opaque` throw), README.
|
|
226
229
|
|
|
227
230
|
### MEDIUM — Bearer resolver in a browser session app (or vice versa)
|