@adonis-agora/authkit-client 0.9.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 +21 -0
- package/README.md +88 -0
- package/build/commands/configure.d.ts +2 -0
- package/build/commands/configure.js +17 -0
- package/build/index.d.ts +28 -0
- package/build/index.js +15 -0
- package/build/providers/authkit_client_provider.d.ts +61 -0
- package/build/providers/authkit_client_provider.js +187 -0
- package/build/src/authenticator.d.ts +41 -0
- package/build/src/authenticator.js +105 -0
- package/build/src/backchannel_logout.d.ts +77 -0
- package/build/src/backchannel_logout.js +114 -0
- package/build/src/define_config.d.ts +80 -0
- package/build/src/define_config.js +41 -0
- package/build/src/discovery.d.ts +55 -0
- package/build/src/discovery.js +90 -0
- package/build/src/dpop.d.ts +50 -0
- package/build/src/dpop.js +48 -0
- package/build/src/http/resilient_fetch.d.ts +30 -0
- package/build/src/http/resilient_fetch.js +26 -0
- package/build/src/lucid_mirror.d.ts +38 -0
- package/build/src/lucid_mirror.js +32 -0
- package/build/src/middleware/auth_middleware.d.ts +29 -0
- package/build/src/middleware/auth_middleware.js +36 -0
- package/build/src/middleware/authkit_middleware.d.ts +5 -0
- package/build/src/middleware/authkit_middleware.js +9 -0
- package/build/src/middleware/backchannel_revocation_middleware.d.ts +18 -0
- package/build/src/middleware/backchannel_revocation_middleware.js +50 -0
- package/build/src/middleware/silent_auth_middleware.d.ts +13 -0
- package/build/src/middleware/silent_auth_middleware.js +14 -0
- package/build/src/observability/client_metrics.d.ts +10 -0
- package/build/src/observability/client_metrics.js +59 -0
- package/build/src/observability/context_bridge.d.ts +7 -0
- package/build/src/observability/context_bridge.js +49 -0
- package/build/src/oidc_login.d.ts +113 -0
- package/build/src/oidc_login.js +123 -0
- package/build/src/register_oidc_client.d.ts +50 -0
- package/build/src/register_oidc_client.js +125 -0
- package/build/src/resolvers/factory.d.ts +41 -0
- package/build/src/resolvers/factory.js +51 -0
- package/build/src/resolvers/identity.d.ts +30 -0
- package/build/src/resolvers/identity.js +49 -0
- package/build/src/resolvers/jwt_resolver.d.ts +20 -0
- package/build/src/resolvers/jwt_resolver.js +34 -0
- package/build/src/resolvers/opaque_resolver.d.ts +43 -0
- package/build/src/resolvers/opaque_resolver.js +51 -0
- package/build/src/resolvers/pat_resolver.d.ts +18 -0
- package/build/src/resolvers/pat_resolver.js +24 -0
- package/build/src/revocation/revocation_store.d.ts +68 -0
- package/build/src/revocation/revocation_store.js +0 -0
- package/build/src/token_source.d.ts +3 -0
- package/build/src/token_source.js +12 -0
- package/build/src/types.d.ts +6 -0
- package/build/src/types.js +1 -0
- package/build/src/user_resolvers.d.ts +41 -0
- package/build/src/user_resolvers.js +42 -0
- package/build/src/verify_access_token.d.ts +37 -0
- package/build/src/verify_access_token.js +43 -0
- package/build/stubs/.gitkeep +0 -0
- package/build/stubs/config/authkit_client.stub +17 -0
- package/build/stubs/controllers/oidc_session_controller.stub +42 -0
- package/build/stubs/main.d.ts +1 -0
- package/build/stubs/main.js +2 -0
- package/build/types.d.ts +13 -0
- package/build/types.js +1 -0
- package/package.json +92 -0
- package/stubs/.gitkeep +0 -0
- package/stubs/config/authkit_client.stub +17 -0
- package/stubs/controllers/oidc_session_controller.stub +42 -0
- package/stubs/main.ts +2 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { buildAuthorizeUrl, buildEndSessionUrl, exchangeCode, generatePkce } from './oidc_login.js';
|
|
3
|
+
/**
|
|
4
|
+
* Registra as rotas OIDC de CLIENT (login/callback/logout [+ back-channel logout])
|
|
5
|
+
* absorvendo o boilerplate que cada app reescrevia no OidcSessionController: PKCE +
|
|
6
|
+
* state na sessão, troca de code, validação, logout RP-initiated e redirect por papel.
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* // start/routes.ts
|
|
10
|
+
* import { registerOidcClient } from '@adonis-agora/authkit-client'
|
|
11
|
+
* registerOidcClient(router, {
|
|
12
|
+
* loginMiddleware: middleware.guest(),
|
|
13
|
+
* redirects: { byGlobalRole: { ADMIN: '/admin' }, byAppRole: { ADVISOR: '/advisor' }, default: '/' },
|
|
14
|
+
* })
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
export function registerOidcClient(router, options = {}) {
|
|
18
|
+
const prefix = options.prefix ?? '/auth';
|
|
19
|
+
const passthrough = options.passthroughParams ?? ['audience'];
|
|
20
|
+
const pkceKey = 'authkit_pkce';
|
|
21
|
+
const loginRoute = router.get(`${prefix}/login`, async (ctx) => {
|
|
22
|
+
const manager = await ctx.containerResolver.make('authkit.client');
|
|
23
|
+
const cfg = manager.clientConfig;
|
|
24
|
+
const { verifier, challenge } = await generatePkce();
|
|
25
|
+
const state = randomUUID();
|
|
26
|
+
ctx.session?.put(pkceKey, { verifier, state });
|
|
27
|
+
const extraParams = {};
|
|
28
|
+
for (const param of passthrough) {
|
|
29
|
+
const v = ctx.request.input(param);
|
|
30
|
+
if (v !== undefined && v !== null && v !== '')
|
|
31
|
+
extraParams[param] = String(v);
|
|
32
|
+
}
|
|
33
|
+
const url = buildAuthorizeUrl({
|
|
34
|
+
issuer: cfg.issuer,
|
|
35
|
+
clientId: cfg.clientId,
|
|
36
|
+
redirectUri: cfg.redirectUri,
|
|
37
|
+
scopes: cfg.scopes,
|
|
38
|
+
state,
|
|
39
|
+
codeChallenge: challenge,
|
|
40
|
+
...(Object.keys(extraParams).length ? { extraParams } : {}),
|
|
41
|
+
});
|
|
42
|
+
return ctx.response.redirect(url);
|
|
43
|
+
});
|
|
44
|
+
if (options.loginMiddleware)
|
|
45
|
+
loginRoute.use(options.loginMiddleware);
|
|
46
|
+
loginRoute.as('auth.login');
|
|
47
|
+
router
|
|
48
|
+
.get(`${prefix}/callback`, async (ctx) => {
|
|
49
|
+
const manager = await ctx.containerResolver.make('authkit.client');
|
|
50
|
+
const cfg = manager.clientConfig;
|
|
51
|
+
const { code, state } = ctx.request.qs();
|
|
52
|
+
const pkce = ctx.session?.get(pkceKey);
|
|
53
|
+
// Callback velho/expirado (state perdido ou code ausente) → recomeça o login.
|
|
54
|
+
if (!pkce || pkce.state !== state || !code) {
|
|
55
|
+
;
|
|
56
|
+
ctx.session?.forget(pkceKey);
|
|
57
|
+
return ctx.response.redirect(`${prefix}/login`);
|
|
58
|
+
}
|
|
59
|
+
const tokenSet = await exchangeCode({
|
|
60
|
+
issuer: cfg.issuer,
|
|
61
|
+
clientId: cfg.clientId,
|
|
62
|
+
clientSecret: cfg.clientSecret,
|
|
63
|
+
redirectUri: cfg.redirectUri,
|
|
64
|
+
code,
|
|
65
|
+
codeVerifier: pkce.verifier,
|
|
66
|
+
});
|
|
67
|
+
ctx.session?.put(cfg.sessionKey, tokenSet);
|
|
68
|
+
ctx.session?.forget(pkceKey);
|
|
69
|
+
const authenticator = await manager.createAuthenticator(ctx);
|
|
70
|
+
const identity = await authenticator.getIdentity();
|
|
71
|
+
// 1) Hook do app tem prioridade quando retorna uma rota.
|
|
72
|
+
const hook = await options.afterLogin?.(ctx, identity);
|
|
73
|
+
if (typeof hook === 'string')
|
|
74
|
+
return ctx.response.redirect(hook);
|
|
75
|
+
// 2) Redirect por papel: global primeiro, depois app.
|
|
76
|
+
const dest = await resolveDestination(authenticator, identity, options.redirects);
|
|
77
|
+
return ctx.response.redirect(dest);
|
|
78
|
+
})
|
|
79
|
+
.as('auth.callback');
|
|
80
|
+
router
|
|
81
|
+
.post(`${prefix}/logout`, async (ctx) => {
|
|
82
|
+
const manager = await ctx.containerResolver.make('authkit.client');
|
|
83
|
+
const cfg = manager.clientConfig;
|
|
84
|
+
const idToken = manager.getIdToken(ctx);
|
|
85
|
+
ctx.session?.forget(cfg.sessionKey);
|
|
86
|
+
const postLogoutRedirectUri = typeof options.postLogoutRedirect === 'function'
|
|
87
|
+
? options.postLogoutRedirect(ctx)
|
|
88
|
+
: (options.postLogoutRedirect ?? new URL(cfg.redirectUri).origin + '/');
|
|
89
|
+
const endSessionUrl = buildEndSessionUrl({
|
|
90
|
+
issuer: cfg.issuer,
|
|
91
|
+
idToken,
|
|
92
|
+
postLogoutRedirectUri,
|
|
93
|
+
clientId: cfg.clientId,
|
|
94
|
+
});
|
|
95
|
+
return ctx.response.redirect(endSessionUrl);
|
|
96
|
+
})
|
|
97
|
+
.as('auth.logout');
|
|
98
|
+
if (options.backchannelLogout !== false) {
|
|
99
|
+
router
|
|
100
|
+
.post(`${prefix}/backchannel-logout`, async (ctx) => {
|
|
101
|
+
const manager = await ctx.containerResolver.make('authkit.client');
|
|
102
|
+
return manager.handleBackchannelLogout(ctx);
|
|
103
|
+
})
|
|
104
|
+
.as('auth.backchannel_logout');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** Resolve o destino pós-login a partir do mapa de redirects por papel. */
|
|
108
|
+
async function resolveDestination(authenticator, identity, redirects) {
|
|
109
|
+
if (!redirects)
|
|
110
|
+
return '/';
|
|
111
|
+
const globalRoles = identity?.globalRoles ?? [];
|
|
112
|
+
if (redirects.byGlobalRole) {
|
|
113
|
+
for (const [role, path] of Object.entries(redirects.byGlobalRole)) {
|
|
114
|
+
if (globalRoles.includes(role))
|
|
115
|
+
return path;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (redirects.byAppRole) {
|
|
119
|
+
for (const [role, path] of Object.entries(redirects.byAppRole)) {
|
|
120
|
+
if (await authenticator.hasAppRole(role))
|
|
121
|
+
return path;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return redirects.default ?? '/';
|
|
125
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { SessionResolver } from '@adonis-agora/authkit-core';
|
|
2
|
+
import { type TokenSource } from '../token_source.js';
|
|
3
|
+
export interface JwtResolverFactoryConfig {
|
|
4
|
+
tokenSource?: TokenSource;
|
|
5
|
+
/** jwks_uri explícito; se omitido, é `${issuer}/jwks` (rota padrão do oidc-provider) */
|
|
6
|
+
jwksUri?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface PatResolverFactoryConfig {
|
|
9
|
+
introspectionUrl: string;
|
|
10
|
+
introspectionSecret: string;
|
|
11
|
+
}
|
|
12
|
+
export interface OpaqueResolverFactoryConfig {
|
|
13
|
+
/**
|
|
14
|
+
* De onde tirar o access token. `session` (default) introspecta o access token
|
|
15
|
+
* guardado no TokenSet; `bearer` introspecta o token do header Authorization (APIs).
|
|
16
|
+
*/
|
|
17
|
+
tokenSource?: TokenSource;
|
|
18
|
+
/** Endpoint de introspection; default `${issuer}/token/introspection` (oidc-provider). */
|
|
19
|
+
introspectionUrl?: string;
|
|
20
|
+
/**
|
|
21
|
+
* TTL (ms) do cache de respostas `active:true`. Default 0 (revogação imediata).
|
|
22
|
+
* Suba para reduzir round-trips ao custo de imediatismo.
|
|
23
|
+
*/
|
|
24
|
+
cacheTtlMs?: number;
|
|
25
|
+
}
|
|
26
|
+
/** Contexto que o provider passa ao resolver na resolução. */
|
|
27
|
+
export interface ResolverContext {
|
|
28
|
+
issuer: string;
|
|
29
|
+
clientId: string;
|
|
30
|
+
clientSecret?: string;
|
|
31
|
+
sessionKey: string;
|
|
32
|
+
globalRolesClaim: string;
|
|
33
|
+
}
|
|
34
|
+
export interface ResolverFactory {
|
|
35
|
+
resolver(ctx: ResolverContext): Promise<SessionResolver>;
|
|
36
|
+
}
|
|
37
|
+
export declare const resolvers: {
|
|
38
|
+
jwt(config?: JwtResolverFactoryConfig): ResolverFactory;
|
|
39
|
+
pat(config: PatResolverFactoryConfig): ResolverFactory;
|
|
40
|
+
opaque(config?: OpaqueResolverFactoryConfig): ResolverFactory;
|
|
41
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { JwtResolver } from './jwt_resolver.js';
|
|
2
|
+
import { PatResolver } from './pat_resolver.js';
|
|
3
|
+
import { OpaqueResolver } from './opaque_resolver.js';
|
|
4
|
+
import { getTokenFromSource } from '../token_source.js';
|
|
5
|
+
export const resolvers = {
|
|
6
|
+
jwt(config = {}) {
|
|
7
|
+
const tokenSource = config.tokenSource ?? 'session';
|
|
8
|
+
return {
|
|
9
|
+
async resolver(rc) {
|
|
10
|
+
const jwksUri = config.jwksUri ?? `${rc.issuer}/jwks`;
|
|
11
|
+
return new JwtResolver({
|
|
12
|
+
issuer: rc.issuer,
|
|
13
|
+
jwksUri,
|
|
14
|
+
audience: rc.clientId,
|
|
15
|
+
globalRolesClaim: rc.globalRolesClaim,
|
|
16
|
+
getToken: (httpCtx) => getTokenFromSource(httpCtx, tokenSource, rc.sessionKey),
|
|
17
|
+
});
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
},
|
|
21
|
+
pat(config) {
|
|
22
|
+
return {
|
|
23
|
+
async resolver(_rc) {
|
|
24
|
+
return new PatResolver({
|
|
25
|
+
introspectionUrl: config.introspectionUrl,
|
|
26
|
+
introspectionSecret: config.introspectionSecret,
|
|
27
|
+
});
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
},
|
|
31
|
+
opaque(config = {}) {
|
|
32
|
+
const tokenSource = config.tokenSource ?? 'session';
|
|
33
|
+
return {
|
|
34
|
+
async resolver(rc) {
|
|
35
|
+
if (!rc.clientSecret) {
|
|
36
|
+
throw new Error('resolvers.opaque requer um client confidencial (clientSecret) para introspection');
|
|
37
|
+
}
|
|
38
|
+
const introspectionUrl = config.introspectionUrl ?? `${rc.issuer}/token/introspection`;
|
|
39
|
+
return new OpaqueResolver({
|
|
40
|
+
introspectionUrl,
|
|
41
|
+
clientId: rc.clientId,
|
|
42
|
+
clientSecret: rc.clientSecret,
|
|
43
|
+
tokenSource,
|
|
44
|
+
sessionKey: rc.sessionKey,
|
|
45
|
+
globalRolesClaim: rc.globalRolesClaim,
|
|
46
|
+
cacheTtlMs: config.cacheTtlMs,
|
|
47
|
+
});
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
},
|
|
51
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Identity } from '@adonis-agora/authkit-core';
|
|
2
|
+
type FetchImpl = (url: string, init: any) => Promise<{
|
|
3
|
+
ok: boolean;
|
|
4
|
+
json: () => Promise<any>;
|
|
5
|
+
}>;
|
|
6
|
+
/**
|
|
7
|
+
* Mapeamento canônico de claims OIDC validadas → Identity, compartilhado pelos
|
|
8
|
+
* resolvers `jwt`, `pat` e `opaque`. Espelha exatamente o que o `JwtResolver`
|
|
9
|
+
* produzia (a referência canônica): `sub`→userId, `email`, papéis na
|
|
10
|
+
* `globalRolesClaim`, `name`/`picture`→profile, `sid`→sessionId, `iat`/`exp`
|
|
11
|
+
* (segundos→ms, fallback 0) e as claims cruas em `raw`.
|
|
12
|
+
*/
|
|
13
|
+
export declare function buildIdentityFromClaims(claims: Record<string, unknown>, globalRolesClaim: string): Identity;
|
|
14
|
+
export interface IntrospectAuth {
|
|
15
|
+
/** `bearer` envia `Authorization: Bearer <value>`; `basic` envia `Authorization: Basic <value>`. */
|
|
16
|
+
type: 'bearer' | 'basic';
|
|
17
|
+
value: string;
|
|
18
|
+
}
|
|
19
|
+
export interface IntrospectOptions {
|
|
20
|
+
/** `token_type_hint` enviado no corpo (RFC 7662). */
|
|
21
|
+
tokenTypeHint?: string;
|
|
22
|
+
fetchImpl?: FetchImpl;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Introspecção de token padrão (RFC 7662): POST form-urlencoded com `token`
|
|
26
|
+
* (+ `token_type_hint` opcional) e autenticação Bearer ou Basic. Retorna o
|
|
27
|
+
* payload parseado quando `active:true`; caso contrário (ou em !res.ok) `null`.
|
|
28
|
+
*/
|
|
29
|
+
export declare function introspectToken(url: string, token: string, auth: IntrospectAuth, opts?: IntrospectOptions): Promise<Record<string, any> | null>;
|
|
30
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mapeamento canônico de claims OIDC validadas → Identity, compartilhado pelos
|
|
3
|
+
* resolvers `jwt`, `pat` e `opaque`. Espelha exatamente o que o `JwtResolver`
|
|
4
|
+
* produzia (a referência canônica): `sub`→userId, `email`, papéis na
|
|
5
|
+
* `globalRolesClaim`, `name`/`picture`→profile, `sid`→sessionId, `iat`/`exp`
|
|
6
|
+
* (segundos→ms, fallback 0) e as claims cruas em `raw`.
|
|
7
|
+
*/
|
|
8
|
+
export function buildIdentityFromClaims(claims, globalRolesClaim) {
|
|
9
|
+
const roles = claims[globalRolesClaim];
|
|
10
|
+
return {
|
|
11
|
+
userId: String(claims.sub ?? ''),
|
|
12
|
+
email: typeof claims.email === 'string' ? claims.email : '',
|
|
13
|
+
globalRoles: Array.isArray(roles) ? roles : [],
|
|
14
|
+
profile: {
|
|
15
|
+
name: typeof claims.name === 'string' ? claims.name : undefined,
|
|
16
|
+
avatarUrl: typeof claims.picture === 'string' ? claims.picture : undefined,
|
|
17
|
+
},
|
|
18
|
+
sessionId: typeof claims.sid === 'string' ? claims.sid : undefined,
|
|
19
|
+
issuedAt: typeof claims.iat === 'number' ? claims.iat * 1000 : 0,
|
|
20
|
+
expiresAt: typeof claims.exp === 'number' ? claims.exp * 1000 : 0,
|
|
21
|
+
raw: claims,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Introspecção de token padrão (RFC 7662): POST form-urlencoded com `token`
|
|
26
|
+
* (+ `token_type_hint` opcional) e autenticação Bearer ou Basic. Retorna o
|
|
27
|
+
* payload parseado quando `active:true`; caso contrário (ou em !res.ok) `null`.
|
|
28
|
+
*/
|
|
29
|
+
export async function introspectToken(url, token, auth, opts = {}) {
|
|
30
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
31
|
+
const params = new URLSearchParams({ token });
|
|
32
|
+
if (opts.tokenTypeHint)
|
|
33
|
+
params.set('token_type_hint', opts.tokenTypeHint);
|
|
34
|
+
const authValue = auth.type === 'bearer' ? `Bearer ${auth.value}` : `Basic ${auth.value}`;
|
|
35
|
+
const res = await doFetch(url, {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers: {
|
|
38
|
+
authorization: authValue,
|
|
39
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
40
|
+
},
|
|
41
|
+
body: params.toString(),
|
|
42
|
+
});
|
|
43
|
+
if (!res.ok)
|
|
44
|
+
return null;
|
|
45
|
+
const data = await res.json();
|
|
46
|
+
if (!data?.active)
|
|
47
|
+
return null;
|
|
48
|
+
return data;
|
|
49
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { HttpContext } from '@adonisjs/core/http';
|
|
2
|
+
import type { Identity, SessionResolver } from '@adonis-agora/authkit-core';
|
|
3
|
+
export interface JwtResolverConfig {
|
|
4
|
+
issuer: string;
|
|
5
|
+
jwksUri: string;
|
|
6
|
+
audience: string;
|
|
7
|
+
globalRolesClaim: string;
|
|
8
|
+
/** algoritmos de assinatura aceitos (defesa contra alg-confusion). Default: asimétricos. */
|
|
9
|
+
algorithms?: string[];
|
|
10
|
+
/** de onde extrair o token cru no request; injetado pelo factory */
|
|
11
|
+
getToken?: (ctx: HttpContext) => string | null;
|
|
12
|
+
}
|
|
13
|
+
export declare class JwtResolver implements SessionResolver {
|
|
14
|
+
#private;
|
|
15
|
+
private config;
|
|
16
|
+
constructor(config: JwtResolverConfig);
|
|
17
|
+
/** Valida um JWT cru e monta a Identity (ou null se inválido). */
|
|
18
|
+
resolveToken(token: string): Promise<Identity | null>;
|
|
19
|
+
resolve(ctx: HttpContext): Promise<Identity | null>;
|
|
20
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
|
2
|
+
import { buildIdentityFromClaims } from './identity.js';
|
|
3
|
+
const DEFAULT_ALGS = ['RS256', 'RS384', 'RS512', 'PS256', 'PS384', 'PS512', 'ES256', 'ES384', 'ES512', 'EdDSA'];
|
|
4
|
+
export class JwtResolver {
|
|
5
|
+
config;
|
|
6
|
+
#jwks;
|
|
7
|
+
constructor(config) {
|
|
8
|
+
this.config = config;
|
|
9
|
+
this.#jwks = createRemoteJWKSet(new URL(config.jwksUri));
|
|
10
|
+
}
|
|
11
|
+
/** Valida um JWT cru e monta a Identity (ou null se inválido). */
|
|
12
|
+
async resolveToken(token) {
|
|
13
|
+
try {
|
|
14
|
+
const { payload } = await jwtVerify(token, this.#jwks, {
|
|
15
|
+
issuer: this.config.issuer,
|
|
16
|
+
audience: this.config.audience,
|
|
17
|
+
algorithms: this.config.algorithms ?? DEFAULT_ALGS,
|
|
18
|
+
});
|
|
19
|
+
// uma identidade válida exige um subject (`sub`)
|
|
20
|
+
if (!payload.sub)
|
|
21
|
+
return null;
|
|
22
|
+
return buildIdentityFromClaims(payload, this.config.globalRolesClaim);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
async resolve(ctx) {
|
|
29
|
+
const token = this.config.getToken?.(ctx) ?? null;
|
|
30
|
+
if (!token)
|
|
31
|
+
return null;
|
|
32
|
+
return this.resolveToken(token);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { HttpContext } from '@adonisjs/core/http';
|
|
2
|
+
import type { Identity, SessionResolver } from '@adonis-agora/authkit-core';
|
|
3
|
+
import { type TokenSource } from '../token_source.js';
|
|
4
|
+
type FetchImpl = (url: string, init: any) => Promise<{
|
|
5
|
+
ok: boolean;
|
|
6
|
+
json: () => Promise<any>;
|
|
7
|
+
}>;
|
|
8
|
+
export interface OpaqueResolverConfig {
|
|
9
|
+
/** Endpoint de introspection do IdP (RFC 7662). Ex.: `${issuer}/token/introspection`. */
|
|
10
|
+
introspectionUrl: string;
|
|
11
|
+
/** client_id usado para autenticar a introspection (client confidencial). */
|
|
12
|
+
clientId: string;
|
|
13
|
+
/** client_secret correspondente (Basic auth). */
|
|
14
|
+
clientSecret: string;
|
|
15
|
+
/** De onde tirar o access token: `session` (TokenSet) ou `bearer` (header). */
|
|
16
|
+
tokenSource: TokenSource;
|
|
17
|
+
/** Chave da sessão onde o TokenSet está guardado. */
|
|
18
|
+
sessionKey: string;
|
|
19
|
+
/** Claim que carrega os papéis globais no payload da introspection. */
|
|
20
|
+
globalRolesClaim: string;
|
|
21
|
+
/**
|
|
22
|
+
* TTL (ms) do cache em memória de respostas `active:true`, para não introspectar
|
|
23
|
+
* a cada request. Default 0 (sem cache → revogação propaga imediatamente). Suba
|
|
24
|
+
* para trocar imediatismo por menos round-trips.
|
|
25
|
+
*/
|
|
26
|
+
cacheTtlMs?: number;
|
|
27
|
+
fetchImpl?: FetchImpl;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Resolver de access token OPACO via introspection padrão (RFC 7662) a cada request.
|
|
31
|
+
*
|
|
32
|
+
* Diferente do `jwt` (id_token stateless, sem checagem de revogação) e do `pat`
|
|
33
|
+
* (token `pat_` com segredo compartilhado): aqui o access token opaco emitido pelo
|
|
34
|
+
* fluxo OIDC é introspectado no IdP a cada request, então uma revogação no IdP
|
|
35
|
+
* derruba a sessão do app imediatamente (no próximo request, ou após o `cacheTtlMs`).
|
|
36
|
+
*/
|
|
37
|
+
export declare class OpaqueResolver implements SessionResolver {
|
|
38
|
+
#private;
|
|
39
|
+
private config;
|
|
40
|
+
constructor(config: OpaqueResolverConfig);
|
|
41
|
+
resolve(ctx: HttpContext): Promise<Identity | null>;
|
|
42
|
+
}
|
|
43
|
+
export {};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { getTokenFromSource } from '../token_source.js';
|
|
2
|
+
import { buildIdentityFromClaims, introspectToken } from './identity.js';
|
|
3
|
+
/**
|
|
4
|
+
* Resolver de access token OPACO via introspection padrão (RFC 7662) a cada request.
|
|
5
|
+
*
|
|
6
|
+
* Diferente do `jwt` (id_token stateless, sem checagem de revogação) e do `pat`
|
|
7
|
+
* (token `pat_` com segredo compartilhado): aqui o access token opaco emitido pelo
|
|
8
|
+
* fluxo OIDC é introspectado no IdP a cada request, então uma revogação no IdP
|
|
9
|
+
* derruba a sessão do app imediatamente (no próximo request, ou após o `cacheTtlMs`).
|
|
10
|
+
*/
|
|
11
|
+
export class OpaqueResolver {
|
|
12
|
+
config;
|
|
13
|
+
#cache = new Map();
|
|
14
|
+
constructor(config) {
|
|
15
|
+
this.config = config;
|
|
16
|
+
}
|
|
17
|
+
#getToken(ctx) {
|
|
18
|
+
if (this.config.tokenSource === 'bearer') {
|
|
19
|
+
return getTokenFromSource(ctx, 'bearer', this.config.sessionKey);
|
|
20
|
+
}
|
|
21
|
+
// session: introspectamos o ACCESS token (não o id_token).
|
|
22
|
+
const tokenSet = ctx.session?.get(this.config.sessionKey);
|
|
23
|
+
return tokenSet?.accessToken ?? null;
|
|
24
|
+
}
|
|
25
|
+
async resolve(ctx) {
|
|
26
|
+
const token = this.#getToken(ctx);
|
|
27
|
+
if (!token)
|
|
28
|
+
return null;
|
|
29
|
+
const ttl = this.config.cacheTtlMs ?? 0;
|
|
30
|
+
if (ttl > 0) {
|
|
31
|
+
const hit = this.#cache.get(token);
|
|
32
|
+
// `nowMs` vem do ctx (request) para não depender de Date.* — ver #now().
|
|
33
|
+
if (hit && this.#now() - hit.storedAt < ttl)
|
|
34
|
+
return hit.identity;
|
|
35
|
+
}
|
|
36
|
+
const basic = Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).toString('base64');
|
|
37
|
+
const data = await introspectToken(this.config.introspectionUrl, token, { type: 'basic', value: basic }, { tokenTypeHint: 'access_token', fetchImpl: this.config.fetchImpl });
|
|
38
|
+
if (!data) {
|
|
39
|
+
this.#cache.delete(token);
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
const identity = buildIdentityFromClaims(data, this.config.globalRolesClaim);
|
|
43
|
+
if (ttl > 0)
|
|
44
|
+
this.#cache.set(token, { identity, storedAt: this.#now() });
|
|
45
|
+
return identity;
|
|
46
|
+
}
|
|
47
|
+
/** Relógio monotônico de processo (evita Date.* direto; testável). */
|
|
48
|
+
#now() {
|
|
49
|
+
return Math.trunc(performance.now());
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { HttpContext } from '@adonisjs/core/http';
|
|
2
|
+
import type { Identity, SessionResolver } from '@adonis-agora/authkit-core';
|
|
3
|
+
type FetchImpl = (url: string, init: any) => Promise<{
|
|
4
|
+
ok: boolean;
|
|
5
|
+
json: () => Promise<any>;
|
|
6
|
+
}>;
|
|
7
|
+
export interface PatResolverConfig {
|
|
8
|
+
introspectionUrl: string;
|
|
9
|
+
introspectionSecret: string;
|
|
10
|
+
fetchImpl?: FetchImpl;
|
|
11
|
+
}
|
|
12
|
+
export declare class PatResolver implements SessionResolver {
|
|
13
|
+
#private;
|
|
14
|
+
private config;
|
|
15
|
+
constructor(config: PatResolverConfig);
|
|
16
|
+
resolve(ctx: HttpContext): Promise<Identity | null>;
|
|
17
|
+
}
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { buildIdentityFromClaims, introspectToken } from './identity.js';
|
|
2
|
+
export class PatResolver {
|
|
3
|
+
config;
|
|
4
|
+
constructor(config) {
|
|
5
|
+
this.config = config;
|
|
6
|
+
}
|
|
7
|
+
#getToken(ctx) {
|
|
8
|
+
const header = ctx.request.header('authorization');
|
|
9
|
+
if (!header || !header.startsWith('Bearer '))
|
|
10
|
+
return null;
|
|
11
|
+
const token = header.slice(7).trim();
|
|
12
|
+
return token.startsWith('pat_') ? token : null;
|
|
13
|
+
}
|
|
14
|
+
async resolve(ctx) {
|
|
15
|
+
const token = this.#getToken(ctx);
|
|
16
|
+
if (!token)
|
|
17
|
+
return null;
|
|
18
|
+
const data = await introspectToken(this.config.introspectionUrl, token, { type: 'bearer', value: this.config.introspectionSecret }, { fetchImpl: this.config.fetchImpl });
|
|
19
|
+
if (!data)
|
|
20
|
+
return null;
|
|
21
|
+
// PAT carrega os papéis na claim literal `roles`.
|
|
22
|
+
return buildIdentityFromClaims(data, 'roles');
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Store de revogações de sessão para OIDC Back-Channel Logout sobre sessões
|
|
3
|
+
* COOKIE-BASED (sem store server-side).
|
|
4
|
+
*
|
|
5
|
+
* Diferente do {@link SessionIndex} (que mapeia sid/sub -> sessionId local e só
|
|
6
|
+
* funciona quando há um store de sessão server-side com id estável), este store
|
|
7
|
+
* implementa o padrão "log de revogação + checagem na request":
|
|
8
|
+
*
|
|
9
|
+
* 1. ao receber um logout_token válido, grava-se a revogação (sid e/ou sub);
|
|
10
|
+
* 2. em toda request, o {@link BackchannelRevocationMiddleware} extrai sid/sub/iat
|
|
11
|
+
* do id_token guardado na sessão e pergunta `isRevoked()`;
|
|
12
|
+
* 3. se revogada, o middleware limpa o token set da sessão local.
|
|
13
|
+
*
|
|
14
|
+
* Semântica de revogação:
|
|
15
|
+
* - por `sid` → derruba UMA sessão SSO específica (logout normal no IdP);
|
|
16
|
+
* - por `sub` → derruba TODAS as sessões do usuário criadas ANTES de `revokedAt`
|
|
17
|
+
* (revogação em massa). A comparação com o `iat` do id_token garante que logins
|
|
18
|
+
* POSTERIORES à revogação continuem válidos.
|
|
19
|
+
*/
|
|
20
|
+
export interface RevocationStore {
|
|
21
|
+
/** Persiste uma revogação recebida via back-channel logout (ou disparada pelo IdP). */
|
|
22
|
+
revoke(event: {
|
|
23
|
+
sid?: string;
|
|
24
|
+
sub?: string;
|
|
25
|
+
}): Promise<void>;
|
|
26
|
+
/**
|
|
27
|
+
* @param authTime `iat` (epoch seconds) do id_token da sessão local — o momento
|
|
28
|
+
* em que ESTA sessão foi estabelecida. Usado para o caso `sub` (massa).
|
|
29
|
+
*/
|
|
30
|
+
isRevoked(params: {
|
|
31
|
+
sid?: string;
|
|
32
|
+
sub?: string;
|
|
33
|
+
authTime?: number;
|
|
34
|
+
}): Promise<boolean>;
|
|
35
|
+
/** Remove revogações mais antigas que `olderThanDays` (default 35). */
|
|
36
|
+
prune(olderThanDays?: number): Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
export interface LucidRevocationStoreOptions {
|
|
39
|
+
/**
|
|
40
|
+
* Conexão Lucid a usar. Default: conexão primária. Aponte para uma conexão cujo
|
|
41
|
+
* `searchPath` enxergue o schema onde a tabela vive (ex.: `auth`). A tabela é
|
|
42
|
+
* compartilhável entre apps que apontam para o MESMO banco.
|
|
43
|
+
*/
|
|
44
|
+
connection?: string;
|
|
45
|
+
/** Nome da tabela. Default: `auth_session_revocations` (criada pelo schema auto-manage do server). */
|
|
46
|
+
table?: string;
|
|
47
|
+
/**
|
|
48
|
+
* Auto-prune OPORTUNÍSTICO (default LIGADO): ao gravar uma revogação, se já
|
|
49
|
+
* passou `everyHours` desde o último prune NESTE processo, remove revogações
|
|
50
|
+
* mais velhas que `olderThanDays`. Dispensa qualquer agendamento no app — a
|
|
51
|
+
* limpeza vem da lib. `false` desliga. Default `{ everyHours: 24, olderThanDays: 35 }`.
|
|
52
|
+
*/
|
|
53
|
+
autoPrune?: false | {
|
|
54
|
+
everyHours?: number;
|
|
55
|
+
olderThanDays?: number;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** Nome default da tabela — alinhado ao `ensureAuthkitSchema()` do authkit-server. */
|
|
59
|
+
export declare const DEFAULT_REVOCATION_TABLE = "auth_session_revocations";
|
|
60
|
+
/**
|
|
61
|
+
* Implementação do {@link RevocationStore} sobre `@adonisjs/lucid`. A tabela é
|
|
62
|
+
* append-only (id auto-increment); a leitura/escrita usa o query builder cru, então
|
|
63
|
+
* o consumidor NÃO precisa declarar um model.
|
|
64
|
+
*
|
|
65
|
+
* Requer `@adonisjs/lucid` instalado (peer dependency). O import é lazy para não
|
|
66
|
+
* acoplar o pacote a lucid quando este store não é usado.
|
|
67
|
+
*/
|
|
68
|
+
export declare function lucidRevocationStore(options?: LucidRevocationStoreOptions): RevocationStore;
|
|
Binary file
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function getTokenFromSource(ctx, source, sessionKey) {
|
|
2
|
+
if (source === 'bearer') {
|
|
3
|
+
const header = ctx.request.header('authorization');
|
|
4
|
+
if (!header)
|
|
5
|
+
return null;
|
|
6
|
+
const [scheme, value] = header.split(' ');
|
|
7
|
+
return scheme?.toLowerCase() === 'bearer' && value ? value : null;
|
|
8
|
+
}
|
|
9
|
+
// session: o ID token (que é JWT) é o token validado por JWKS
|
|
10
|
+
const tokenSet = ctx.session?.get(sessionKey);
|
|
11
|
+
return tokenSet?.idToken ?? null;
|
|
12
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Identity } from '@adonis-agora/authkit-core';
|
|
2
|
+
/**
|
|
3
|
+
* Contexto opcional passado como 2º argumento de `resolveUser`.
|
|
4
|
+
* Extensão backward-compatible: callbacks `(identity) => ...` ignoram este arg.
|
|
5
|
+
*/
|
|
6
|
+
export interface ResolveUserContext {
|
|
7
|
+
accessToken?: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Usuário derivado puramente das claims do ID token (caminho claims-only).
|
|
11
|
+
* Útil em topologia de bancos separados, onde o ID token é "gordo".
|
|
12
|
+
*/
|
|
13
|
+
export interface ClaimsUser {
|
|
14
|
+
id: string;
|
|
15
|
+
email: string;
|
|
16
|
+
name?: string;
|
|
17
|
+
avatarUrl?: string;
|
|
18
|
+
globalRoles: string[];
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Converte uma `Identity` (claims OIDC validadas) num objeto de usuário simples.
|
|
22
|
+
* Pode ser usada diretamente como `resolveUser: identityToUser`.
|
|
23
|
+
*/
|
|
24
|
+
export declare function identityToUser(identity: Identity): ClaimsUser;
|
|
25
|
+
export interface UserinfoResolverOptions {
|
|
26
|
+
/** endpoint userinfo explícito; tem precedência sobre `issuer` */
|
|
27
|
+
userinfoEndpoint?: string;
|
|
28
|
+
/** issuer do IdP; default do endpoint = `${issuer}/me` (oidc-provider) */
|
|
29
|
+
issuer?: string;
|
|
30
|
+
/** implementação de fetch (default: global fetch) */
|
|
31
|
+
fetchImpl?: typeof fetch;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Fábrica de um `resolveUser` que busca o usuário no endpoint userinfo do IdP
|
|
35
|
+
* usando o access token. Caminho recomendado quando o app precisa de dados
|
|
36
|
+
* além do que o token carrega (topologia de bancos separados).
|
|
37
|
+
*
|
|
38
|
+
* Sem access token (ex.: tokenSource bearer só com ID token), faz fallback
|
|
39
|
+
* para `identityToUser(identity)`.
|
|
40
|
+
*/
|
|
41
|
+
export declare function createUserinfoResolver(options?: UserinfoResolverOptions): (identity: Identity, context?: ResolveUserContext) => Promise<unknown>;
|