@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,114 @@
|
|
|
1
|
+
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
|
2
|
+
/** Event type da claim `events` exigido pelo OIDC Back-Channel Logout. */
|
|
3
|
+
export const BACKCHANNEL_LOGOUT_EVENT = 'http://schemas.openid.net/event/backchannel-logout';
|
|
4
|
+
/** Algoritmos de assinatura aceitos no logout_token (defesa contra alg-confusion). */
|
|
5
|
+
const DEFAULT_ALGS = [
|
|
6
|
+
'RS256',
|
|
7
|
+
'RS384',
|
|
8
|
+
'RS512',
|
|
9
|
+
'PS256',
|
|
10
|
+
'PS384',
|
|
11
|
+
'PS512',
|
|
12
|
+
'ES256',
|
|
13
|
+
'ES384',
|
|
14
|
+
'ES512',
|
|
15
|
+
'EdDSA',
|
|
16
|
+
];
|
|
17
|
+
/** Erro tipado lançado quando o logout_token não satisfaz as regras do spec. */
|
|
18
|
+
export class InvalidLogoutTokenError extends Error {
|
|
19
|
+
constructor(message) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = 'InvalidLogoutTokenError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Valida um `logout_token` (JWT) recebido no Back-Channel Logout, conforme
|
|
26
|
+
* https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation.
|
|
27
|
+
*
|
|
28
|
+
* Verifica a assinatura contra o JWKS do IdP e aplica as regras do spec:
|
|
29
|
+
* - `iss` bate com o issuer configurado, `aud` inclui o clientId, `iat` presente;
|
|
30
|
+
* - claim `events` é um objeto contendo a chave do evento de back-channel logout;
|
|
31
|
+
* - possui ao menos `sid` ou `sub`;
|
|
32
|
+
* - NÃO contém a claim `nonce` (proibida no logout_token).
|
|
33
|
+
*
|
|
34
|
+
* Lança {@link InvalidLogoutTokenError} em qualquer violação.
|
|
35
|
+
*/
|
|
36
|
+
export async function validateLogoutToken(token, opts) {
|
|
37
|
+
const keys = opts.keys ?? createRemoteJWKSet(new URL(opts.jwksUri ?? `${opts.issuer}/jwks`));
|
|
38
|
+
let payload;
|
|
39
|
+
try {
|
|
40
|
+
const verified = await jwtVerify(token, keys, {
|
|
41
|
+
issuer: opts.issuer,
|
|
42
|
+
audience: opts.clientId,
|
|
43
|
+
algorithms: opts.algorithms ?? DEFAULT_ALGS,
|
|
44
|
+
});
|
|
45
|
+
payload = verified.payload;
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
throw new InvalidLogoutTokenError(`Invalid logout_token (signature/iss/aud): ${err.message}`);
|
|
49
|
+
}
|
|
50
|
+
// `iat` é obrigatório no logout_token.
|
|
51
|
+
if (typeof payload.iat !== 'number') {
|
|
52
|
+
throw new InvalidLogoutTokenError('logout_token is missing the iat claim');
|
|
53
|
+
}
|
|
54
|
+
// `nonce` é PROIBIDO no logout_token (evita confusão com id_token).
|
|
55
|
+
if ('nonce' in payload) {
|
|
56
|
+
throw new InvalidLogoutTokenError('logout_token must not contain the nonce claim');
|
|
57
|
+
}
|
|
58
|
+
// `events` deve ser objeto contendo a chave do evento de back-channel logout.
|
|
59
|
+
const events = payload.events;
|
|
60
|
+
if (typeof events !== 'object' ||
|
|
61
|
+
events === null ||
|
|
62
|
+
Array.isArray(events) ||
|
|
63
|
+
!(BACKCHANNEL_LOGOUT_EVENT in events)) {
|
|
64
|
+
throw new InvalidLogoutTokenError(`logout_token sem o evento "${BACKCHANNEL_LOGOUT_EVENT}" na claim events`);
|
|
65
|
+
}
|
|
66
|
+
const sid = typeof payload.sid === 'string' ? payload.sid : undefined;
|
|
67
|
+
const sub = typeof payload.sub === 'string' ? payload.sub : undefined;
|
|
68
|
+
// Pelo menos um de sid/sub deve estar presente.
|
|
69
|
+
if (!sid && !sub) {
|
|
70
|
+
throw new InvalidLogoutTokenError('logout_token must contain at least sid or sub');
|
|
71
|
+
}
|
|
72
|
+
return { sid, sub };
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Implementação default em memória do {@link SessionIndex}. Mantém o mapeamento
|
|
76
|
+
* sid -> sessionId e sub -> Set<sessionId>. NÃO use em produção multi-instância.
|
|
77
|
+
*/
|
|
78
|
+
export class InMemorySessionIndex {
|
|
79
|
+
#bySid = new Map();
|
|
80
|
+
#bySub = new Map();
|
|
81
|
+
register(entry) {
|
|
82
|
+
if (entry.sid) {
|
|
83
|
+
this.#bySid.set(entry.sid, entry.sessionId);
|
|
84
|
+
}
|
|
85
|
+
const set = this.#bySub.get(entry.sub) ?? new Set();
|
|
86
|
+
set.add(entry.sessionId);
|
|
87
|
+
this.#bySub.set(entry.sub, set);
|
|
88
|
+
}
|
|
89
|
+
revokeBySid(sid) {
|
|
90
|
+
const sessionId = this.#bySid.get(sid);
|
|
91
|
+
if (!sessionId)
|
|
92
|
+
return [];
|
|
93
|
+
this.#bySid.delete(sid);
|
|
94
|
+
// Mantém os índices coerentes: remove o sessionId de qualquer bucket de sub.
|
|
95
|
+
for (const [sub, set] of this.#bySub) {
|
|
96
|
+
if (set.delete(sessionId) && set.size === 0)
|
|
97
|
+
this.#bySub.delete(sub);
|
|
98
|
+
}
|
|
99
|
+
return [sessionId];
|
|
100
|
+
}
|
|
101
|
+
revokeBySub(sub) {
|
|
102
|
+
const set = this.#bySub.get(sub);
|
|
103
|
+
if (!set)
|
|
104
|
+
return [];
|
|
105
|
+
const sessionIds = [...set];
|
|
106
|
+
this.#bySub.delete(sub);
|
|
107
|
+
// Remove as entradas de sid que apontavam p/ essas sessões.
|
|
108
|
+
for (const [sid, sessionId] of this.#bySid) {
|
|
109
|
+
if (set.has(sessionId))
|
|
110
|
+
this.#bySid.delete(sid);
|
|
111
|
+
}
|
|
112
|
+
return sessionIds;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Identity } from "@adonis-agora/authkit-core";
|
|
2
|
+
import { resolvers, type ResolverFactory } from "./resolvers/factory.js";
|
|
3
|
+
import type { SessionIndex } from "./backchannel_logout.js";
|
|
4
|
+
import type { RevocationStore } from "./revocation/revocation_store.js";
|
|
5
|
+
import type { ResiliencePolicy } from "./http/resilient_fetch.js";
|
|
6
|
+
export { resolvers };
|
|
7
|
+
/** Callback invocado quando um logout_token válido chega via Back-Channel Logout. */
|
|
8
|
+
export type BackchannelLogoutCallback = (event: {
|
|
9
|
+
sid?: string;
|
|
10
|
+
sub?: string;
|
|
11
|
+
}) => Promise<void> | void;
|
|
12
|
+
/**
|
|
13
|
+
* Back-Channel Logout "pronto" para sessões cookie-based: passe um {@link RevocationStore}
|
|
14
|
+
* e o authkit cuida de tudo —
|
|
15
|
+
* - deriva o `onBackchannelLogout` (grava a revogação no store);
|
|
16
|
+
* - expõe o store p/ o `BackchannelRevocationMiddleware` enforçar em cada request.
|
|
17
|
+
*
|
|
18
|
+
* Substitui o trio model+service+middleware que o consumidor escrevia à mão.
|
|
19
|
+
*/
|
|
20
|
+
export interface BackchannelLogoutInput {
|
|
21
|
+
/** Store que persiste/consulta revogações (ex.: `lucidRevocationStore({ connection: 'auth' })`). */
|
|
22
|
+
store: RevocationStore;
|
|
23
|
+
}
|
|
24
|
+
export interface ClientConfigInput {
|
|
25
|
+
issuer: string;
|
|
26
|
+
clientId: string;
|
|
27
|
+
clientSecret?: string;
|
|
28
|
+
redirectUri: string;
|
|
29
|
+
resolver: ResolverFactory;
|
|
30
|
+
resolveUser?: (identity: Identity, context: {
|
|
31
|
+
accessToken?: string;
|
|
32
|
+
}) => Promise<unknown>;
|
|
33
|
+
resolveAppRoles?: (identity: Identity) => Promise<string[]>;
|
|
34
|
+
sessionKey?: string;
|
|
35
|
+
scopes?: string[];
|
|
36
|
+
globalRolesClaim?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Invocado após um logout_token VÁLIDO chegar via OIDC Back-Channel Logout. O host
|
|
39
|
+
* usa o sid/sub recebido para destruir as sessões locais correspondentes.
|
|
40
|
+
*
|
|
41
|
+
* Para sessões cookie-based, prefira `backchannelLogout: { store }` — ele deriva
|
|
42
|
+
* este callback automaticamente. Se ambos forem fornecidos, os dois rodam (store primeiro).
|
|
43
|
+
*/
|
|
44
|
+
onBackchannelLogout?: BackchannelLogoutCallback;
|
|
45
|
+
/** Índice sid/sub -> sessão local consultado pelo handler de back-channel logout. */
|
|
46
|
+
sessionIndex?: SessionIndex;
|
|
47
|
+
/** Back-Channel Logout "pronto" para sessões cookie-based — ver {@link BackchannelLogoutInput}. */
|
|
48
|
+
backchannelLogout?: BackchannelLogoutInput;
|
|
49
|
+
/**
|
|
50
|
+
* Política de resiliência OPCIONAL aplicada às chamadas HTTP de saída ao IdP
|
|
51
|
+
* (OIDC discovery + token endpoints). Opt-in: sem ela, o comportamento é um
|
|
52
|
+
* `fetch` puro (nenhuma mudança).
|
|
53
|
+
*
|
|
54
|
+
* Passe o resultado de `wrap(...)` de `@adonis-agora/resilience`, ex.:
|
|
55
|
+
* `resilience: wrap(timeout(2000), retry({ attempts: 3 }))`. Tipado
|
|
56
|
+
* estruturalmente — o authkit-client não importa o pacote de resiliência.
|
|
57
|
+
*/
|
|
58
|
+
resilience?: ResiliencePolicy;
|
|
59
|
+
}
|
|
60
|
+
export interface ResolvedClientConfig {
|
|
61
|
+
issuer: string;
|
|
62
|
+
clientId: string;
|
|
63
|
+
clientSecret?: string;
|
|
64
|
+
redirectUri: string;
|
|
65
|
+
resolverFactory: ResolverFactory;
|
|
66
|
+
resolveUser?: (identity: Identity, context: {
|
|
67
|
+
accessToken?: string;
|
|
68
|
+
}) => Promise<unknown>;
|
|
69
|
+
resolveAppRoles?: (identity: Identity) => Promise<string[]>;
|
|
70
|
+
sessionKey: string;
|
|
71
|
+
scopes: string[];
|
|
72
|
+
globalRolesClaim: string;
|
|
73
|
+
onBackchannelLogout?: BackchannelLogoutCallback;
|
|
74
|
+
sessionIndex?: SessionIndex;
|
|
75
|
+
/** Store de revogação (cookie-based BCL); consumido pelo BackchannelRevocationMiddleware. */
|
|
76
|
+
backchannelStore?: RevocationStore;
|
|
77
|
+
/** Política de resiliência opcional p/ chamadas HTTP de saída ao IdP. Ver {@link ClientConfigInput.resilience}. */
|
|
78
|
+
resilience?: ResiliencePolicy;
|
|
79
|
+
}
|
|
80
|
+
export declare function defineConfig(config: ClientConfigInput): import("@adonisjs/core/types").ConfigProvider<ResolvedClientConfig>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { configProvider } from "@adonisjs/core";
|
|
2
|
+
import { resolvers } from "./resolvers/factory.js";
|
|
3
|
+
export { resolvers };
|
|
4
|
+
export function defineConfig(config) {
|
|
5
|
+
return configProvider.create(async () => {
|
|
6
|
+
const store = config.backchannelLogout?.store;
|
|
7
|
+
// Compõe o callback de back-channel: o store grava a revogação; o callback do
|
|
8
|
+
// usuário (se houver) roda depois. Mantém retrocompat com `onBackchannelLogout`.
|
|
9
|
+
const userHook = config.onBackchannelLogout;
|
|
10
|
+
const onBackchannelLogout = store || userHook
|
|
11
|
+
? async (event) => {
|
|
12
|
+
if (store)
|
|
13
|
+
await store.revoke(event);
|
|
14
|
+
if (userHook)
|
|
15
|
+
await userHook(event);
|
|
16
|
+
}
|
|
17
|
+
: undefined;
|
|
18
|
+
return {
|
|
19
|
+
issuer: config.issuer,
|
|
20
|
+
clientId: config.clientId,
|
|
21
|
+
clientSecret: config.clientSecret,
|
|
22
|
+
redirectUri: config.redirectUri,
|
|
23
|
+
resolverFactory: config.resolver,
|
|
24
|
+
resolveUser: config.resolveUser,
|
|
25
|
+
resolveAppRoles: config.resolveAppRoles,
|
|
26
|
+
sessionKey: config.sessionKey ?? "authkit",
|
|
27
|
+
scopes: config.scopes ?? [
|
|
28
|
+
"openid",
|
|
29
|
+
"profile",
|
|
30
|
+
"email",
|
|
31
|
+
"offline_access",
|
|
32
|
+
"roles",
|
|
33
|
+
],
|
|
34
|
+
globalRolesClaim: config.globalRolesClaim ?? "roles",
|
|
35
|
+
onBackchannelLogout,
|
|
36
|
+
sessionIndex: config.sessionIndex,
|
|
37
|
+
backchannelStore: store,
|
|
38
|
+
resilience: config.resilience,
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OIDC Discovery (RFC 8414 / OpenID Connect Discovery 1.0).
|
|
3
|
+
*
|
|
4
|
+
* Os helpers de fluxo deste pacote historicamente assumiam as rotas do
|
|
5
|
+
* oidc-provider (`/auth`, `/token`, `/jwks`…) — convenção do authkit-server.
|
|
6
|
+
* IdPs de terceiros (Keycloak, Auth0, Okta, Entra…) usam caminhos próprios,
|
|
7
|
+
* publicados em `{issuer}/.well-known/openid-configuration`.
|
|
8
|
+
*
|
|
9
|
+
* `discoverEndpoints()` resolve os endpoints reais do IdP (com cache por
|
|
10
|
+
* issuer) e cai na convenção do oidc-provider quando o documento não está
|
|
11
|
+
* disponível — então authkit-server continua funcionando offline/sem rede.
|
|
12
|
+
*
|
|
13
|
+
* ```ts
|
|
14
|
+
* const endpoints = await discoverEndpoints(issuer)
|
|
15
|
+
* const url = buildAuthorizeUrl({ issuer, authorizationEndpoint: endpoints.authorizationEndpoint, ... })
|
|
16
|
+
* const tokens = await exchangeCode({ issuer, tokenEndpoint: endpoints.tokenEndpoint, ... })
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
import { type ResiliencePolicy } from "./http/resilient_fetch.js";
|
|
20
|
+
export interface OidcEndpoints {
|
|
21
|
+
authorizationEndpoint: string;
|
|
22
|
+
tokenEndpoint: string;
|
|
23
|
+
jwksUri: string;
|
|
24
|
+
endSessionEndpoint?: string;
|
|
25
|
+
userinfoEndpoint?: string;
|
|
26
|
+
introspectionEndpoint?: string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Convenção do oidc-provider (authkit-server) — usada como fallback quando o
|
|
30
|
+
* documento de discovery não está acessível.
|
|
31
|
+
*/
|
|
32
|
+
export declare function conventionEndpoints(issuer: string): OidcEndpoints;
|
|
33
|
+
export interface DiscoverOptions {
|
|
34
|
+
/** Overrides manuais — precedem o documento de discovery, campo a campo. */
|
|
35
|
+
overrides?: Partial<OidcEndpoints>;
|
|
36
|
+
/** TTL do cache por issuer. Default: 15 min. */
|
|
37
|
+
cacheTtlMs?: number;
|
|
38
|
+
fetchImpl?: typeof fetch;
|
|
39
|
+
/**
|
|
40
|
+
* Política de resiliência OPCIONAL p/ a chamada de discovery. Sem ela, é um
|
|
41
|
+
* `fetch` puro (nenhuma mudança de comportamento).
|
|
42
|
+
*/
|
|
43
|
+
resilience?: ResiliencePolicy;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Resolve os endpoints do IdP via `/.well-known/openid-configuration`.
|
|
47
|
+
*
|
|
48
|
+
* - Resultado cacheado por issuer (TTL 15 min) — seguro de chamar por request.
|
|
49
|
+
* - Falha de rede/404 → fallback silencioso para a convenção do oidc-provider
|
|
50
|
+
* (o cache também guarda o fallback, evitando refetch em loop).
|
|
51
|
+
* - `overrides` sempre vencem, campo a campo.
|
|
52
|
+
*/
|
|
53
|
+
export declare function discoverEndpoints(issuer: string, options?: DiscoverOptions): Promise<OidcEndpoints>;
|
|
54
|
+
/** Limpa o cache de discovery (testes). */
|
|
55
|
+
export declare function __clearDiscoveryCacheForTests(): void;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OIDC Discovery (RFC 8414 / OpenID Connect Discovery 1.0).
|
|
3
|
+
*
|
|
4
|
+
* Os helpers de fluxo deste pacote historicamente assumiam as rotas do
|
|
5
|
+
* oidc-provider (`/auth`, `/token`, `/jwks`…) — convenção do authkit-server.
|
|
6
|
+
* IdPs de terceiros (Keycloak, Auth0, Okta, Entra…) usam caminhos próprios,
|
|
7
|
+
* publicados em `{issuer}/.well-known/openid-configuration`.
|
|
8
|
+
*
|
|
9
|
+
* `discoverEndpoints()` resolve os endpoints reais do IdP (com cache por
|
|
10
|
+
* issuer) e cai na convenção do oidc-provider quando o documento não está
|
|
11
|
+
* disponível — então authkit-server continua funcionando offline/sem rede.
|
|
12
|
+
*
|
|
13
|
+
* ```ts
|
|
14
|
+
* const endpoints = await discoverEndpoints(issuer)
|
|
15
|
+
* const url = buildAuthorizeUrl({ issuer, authorizationEndpoint: endpoints.authorizationEndpoint, ... })
|
|
16
|
+
* const tokens = await exchangeCode({ issuer, tokenEndpoint: endpoints.tokenEndpoint, ... })
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
import { resilientFetch, } from "./http/resilient_fetch.js";
|
|
20
|
+
/**
|
|
21
|
+
* Convenção do oidc-provider (authkit-server) — usada como fallback quando o
|
|
22
|
+
* documento de discovery não está acessível.
|
|
23
|
+
*/
|
|
24
|
+
export function conventionEndpoints(issuer) {
|
|
25
|
+
const base = issuer.replace(/\/$/, "");
|
|
26
|
+
return {
|
|
27
|
+
authorizationEndpoint: `${base}/auth`,
|
|
28
|
+
tokenEndpoint: `${base}/token`,
|
|
29
|
+
jwksUri: `${base}/jwks`,
|
|
30
|
+
endSessionEndpoint: `${base}/session/end`,
|
|
31
|
+
userinfoEndpoint: `${base}/me`,
|
|
32
|
+
introspectionEndpoint: `${base}/token/introspection`,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const DEFAULT_TTL_MS = 15 * 60_000;
|
|
36
|
+
const cache = new Map();
|
|
37
|
+
/**
|
|
38
|
+
* Resolve os endpoints do IdP via `/.well-known/openid-configuration`.
|
|
39
|
+
*
|
|
40
|
+
* - Resultado cacheado por issuer (TTL 15 min) — seguro de chamar por request.
|
|
41
|
+
* - Falha de rede/404 → fallback silencioso para a convenção do oidc-provider
|
|
42
|
+
* (o cache também guarda o fallback, evitando refetch em loop).
|
|
43
|
+
* - `overrides` sempre vencem, campo a campo.
|
|
44
|
+
*/
|
|
45
|
+
export async function discoverEndpoints(issuer, options = {}) {
|
|
46
|
+
const base = issuer.replace(/\/$/, "");
|
|
47
|
+
const ttl = options.cacheTtlMs ?? DEFAULT_TTL_MS;
|
|
48
|
+
const cached = cache.get(base);
|
|
49
|
+
let endpoints;
|
|
50
|
+
if (cached && Date.now() - cached.fetchedAt < ttl) {
|
|
51
|
+
endpoints = cached.endpoints;
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
endpoints = await fetchDiscovery(base, options.fetchImpl ?? fetch, options.resilience);
|
|
55
|
+
cache.set(base, { endpoints, fetchedAt: Date.now() });
|
|
56
|
+
}
|
|
57
|
+
if (!options.overrides)
|
|
58
|
+
return endpoints;
|
|
59
|
+
const merged = { ...endpoints };
|
|
60
|
+
for (const [key, value] of Object.entries(options.overrides)) {
|
|
61
|
+
if (value !== undefined)
|
|
62
|
+
merged[key] = value;
|
|
63
|
+
}
|
|
64
|
+
return merged;
|
|
65
|
+
}
|
|
66
|
+
async function fetchDiscovery(base, fetchImpl, resilience) {
|
|
67
|
+
const fallback = conventionEndpoints(base);
|
|
68
|
+
try {
|
|
69
|
+
const res = await resilientFetch(`${base}/.well-known/openid-configuration`, { headers: { accept: "application/json" } }, resilience, fetchImpl);
|
|
70
|
+
if (!res.ok)
|
|
71
|
+
return fallback;
|
|
72
|
+
const doc = (await res.json());
|
|
73
|
+
const str = (v) => typeof v === "string" && v ? v : undefined;
|
|
74
|
+
return {
|
|
75
|
+
authorizationEndpoint: str(doc.authorization_endpoint) ?? fallback.authorizationEndpoint,
|
|
76
|
+
tokenEndpoint: str(doc.token_endpoint) ?? fallback.tokenEndpoint,
|
|
77
|
+
jwksUri: str(doc.jwks_uri) ?? fallback.jwksUri,
|
|
78
|
+
endSessionEndpoint: str(doc.end_session_endpoint) ?? fallback.endSessionEndpoint,
|
|
79
|
+
userinfoEndpoint: str(doc.userinfo_endpoint) ?? fallback.userinfoEndpoint,
|
|
80
|
+
introspectionEndpoint: str(doc.introspection_endpoint) ?? fallback.introspectionEndpoint,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return fallback;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** Limpa o cache de discovery (testes). */
|
|
88
|
+
export function __clearDiscoveryCacheForTests() {
|
|
89
|
+
cache.clear();
|
|
90
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type JWK, type KeyLike } from 'jose';
|
|
2
|
+
/**
|
|
3
|
+
* Par de chaves DPoP (RFC 9449). `privateKey`/`publicKey` são KeyLike do jose,
|
|
4
|
+
* usáveis direto em {@link createDpopProof}. As versões JWK são exportáveis para
|
|
5
|
+
* persistir/restaurar o par entre requests (ex.: sessão do client).
|
|
6
|
+
*/
|
|
7
|
+
export interface DpopKeyPair {
|
|
8
|
+
privateKey: KeyLike;
|
|
9
|
+
publicKey: KeyLike;
|
|
10
|
+
/** JWK pública (vai no header `jwk` da prova). */
|
|
11
|
+
publicJwk: JWK;
|
|
12
|
+
/** JWK privada — guarde com cuidado (só o client a possui). */
|
|
13
|
+
privateJwk: JWK;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Gera um par de chaves DPoP ES256 (P-256), exportável como JWK. ES256 é o alg
|
|
17
|
+
* recomendado pelo RFC 9449 e o que o oidc-provider do authkit-server aceita.
|
|
18
|
+
*/
|
|
19
|
+
export declare function generateDpopKeyPair(): Promise<DpopKeyPair>;
|
|
20
|
+
export interface CreateDpopProofInput {
|
|
21
|
+
/** Par de chaves DPoP (de {@link generateDpopKeyPair}). */
|
|
22
|
+
key: DpopKeyPair;
|
|
23
|
+
/** HTTP method da request alvo (ex.: 'POST'). Vira a claim `htm`. */
|
|
24
|
+
htm: string;
|
|
25
|
+
/** HTTP URI alvo SEM query/fragment (ex.: 'https://auth/token'). Vira `htu`. */
|
|
26
|
+
htu: string;
|
|
27
|
+
/** Nonce do servidor (DPoP-Nonce), quando exigido. */
|
|
28
|
+
nonce?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Access token quando a prova acompanha uma request a um recurso protegido:
|
|
31
|
+
* adiciona a claim `ath` = base64url(sha256(accessToken)) (RFC 9449 §4.2).
|
|
32
|
+
*/
|
|
33
|
+
accessToken?: string;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Cria uma prova DPoP (JWT) conforme RFC 9449.
|
|
37
|
+
*
|
|
38
|
+
* - header: `typ: 'dpop+jwt'`, `alg: 'ES256'`, `jwk` = chave PÚBLICA.
|
|
39
|
+
* - claims: `jti` (único), `htm`, `htu`, `iat`; `ath` quando `accessToken` é dado;
|
|
40
|
+
* `nonce` quando dado.
|
|
41
|
+
*
|
|
42
|
+
* O resultado vai no header HTTP `DPoP` da request.
|
|
43
|
+
*/
|
|
44
|
+
export declare function createDpopProof(input: CreateDpopProofInput): Promise<string>;
|
|
45
|
+
/**
|
|
46
|
+
* Calcula o JWK thumbprint (jkt) da chave pública DPoP — o valor que o AS coloca
|
|
47
|
+
* em `cnf.jkt` ao emitir tokens sender-constrained. Útil para o client conferir
|
|
48
|
+
* o binding do token recebido.
|
|
49
|
+
*/
|
|
50
|
+
export declare function dpopJwkThumbprint(key: DpopKeyPair): Promise<string>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { SignJWT, exportJWK, generateKeyPair, calculateJwkThumbprint, base64url, } from 'jose';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
/**
|
|
4
|
+
* Gera um par de chaves DPoP ES256 (P-256), exportável como JWK. ES256 é o alg
|
|
5
|
+
* recomendado pelo RFC 9449 e o que o oidc-provider do authkit-server aceita.
|
|
6
|
+
*/
|
|
7
|
+
export async function generateDpopKeyPair() {
|
|
8
|
+
const { privateKey, publicKey } = await generateKeyPair('ES256', { extractable: true });
|
|
9
|
+
const [publicJwk, privateJwk] = await Promise.all([exportJWK(publicKey), exportJWK(privateKey)]);
|
|
10
|
+
return { privateKey, publicKey, publicJwk, privateJwk };
|
|
11
|
+
}
|
|
12
|
+
/** ath = base64url( SHA-256( ASCII(access_token) ) ). */
|
|
13
|
+
function accessTokenHash(accessToken) {
|
|
14
|
+
return base64url.encode(createHash('sha256').update(accessToken, 'ascii').digest());
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Cria uma prova DPoP (JWT) conforme RFC 9449.
|
|
18
|
+
*
|
|
19
|
+
* - header: `typ: 'dpop+jwt'`, `alg: 'ES256'`, `jwk` = chave PÚBLICA.
|
|
20
|
+
* - claims: `jti` (único), `htm`, `htu`, `iat`; `ath` quando `accessToken` é dado;
|
|
21
|
+
* `nonce` quando dado.
|
|
22
|
+
*
|
|
23
|
+
* O resultado vai no header HTTP `DPoP` da request.
|
|
24
|
+
*/
|
|
25
|
+
export async function createDpopProof(input) {
|
|
26
|
+
const { key, htm, htu, nonce, accessToken } = input;
|
|
27
|
+
const payload = {
|
|
28
|
+
jti: randomUUID(),
|
|
29
|
+
htm: htm.toUpperCase(),
|
|
30
|
+
htu,
|
|
31
|
+
};
|
|
32
|
+
if (accessToken)
|
|
33
|
+
payload.ath = accessTokenHash(accessToken);
|
|
34
|
+
if (nonce)
|
|
35
|
+
payload.nonce = nonce;
|
|
36
|
+
return new SignJWT(payload)
|
|
37
|
+
.setProtectedHeader({ typ: 'dpop+jwt', alg: 'ES256', jwk: key.publicJwk })
|
|
38
|
+
.setIssuedAt()
|
|
39
|
+
.sign(key.privateKey);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Calcula o JWK thumbprint (jkt) da chave pública DPoP — o valor que o AS coloca
|
|
43
|
+
* em `cnf.jkt` ao emitir tokens sender-constrained. Útil para o client conferir
|
|
44
|
+
* o binding do token recebido.
|
|
45
|
+
*/
|
|
46
|
+
export async function dpopJwkThumbprint(key) {
|
|
47
|
+
return calculateJwkThumbprint(key.publicJwk, 'sha256');
|
|
48
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helper interno para as chamadas HTTP de saída do authkit-client (OIDC
|
|
3
|
+
* discovery, JWKS e token endpoints).
|
|
4
|
+
*
|
|
5
|
+
* Por padrão é um passthrough puro de `fetch` — ZERO mudança de comportamento.
|
|
6
|
+
* Quando uma {@link ResiliencePolicy} é fornecida (via config do client), a
|
|
7
|
+
* chamada `fetch` roda dentro de `policy.execute(() => fetch(...))`, ganhando
|
|
8
|
+
* timeout/retry/circuit-breaker conforme a política composta.
|
|
9
|
+
*
|
|
10
|
+
* A política é tipada ESTRUTURALMENTE de propósito: o authkit-client NÃO importa
|
|
11
|
+
* `@adonis-agora/resilience` em runtime. O consumidor passa o resultado de
|
|
12
|
+
* `wrap(...)` (de @adonis-agora/resilience) e o duck-typing casa com a interface.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Forma estrutural de uma política de resiliência composta — exatamente o que
|
|
16
|
+
* `wrap(...)` de `@adonis-agora/resilience` retorna. Tipada aqui para evitar um
|
|
17
|
+
* hard-import do pacote de resiliência.
|
|
18
|
+
*/
|
|
19
|
+
export interface ResiliencePolicy {
|
|
20
|
+
execute<T>(fn: () => Promise<T>): Promise<T>;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* `fetch` envolvido por uma política de resiliência OPCIONAL.
|
|
24
|
+
*
|
|
25
|
+
* - Sem `policy`: chama `fetch(input, init)` diretamente (passthrough).
|
|
26
|
+
* - Com `policy`: roda `policy.execute(() => fetch(input, init))`.
|
|
27
|
+
*
|
|
28
|
+
* `fetchImpl` permite injetar um fetch fake nos testes.
|
|
29
|
+
*/
|
|
30
|
+
export declare function resilientFetch(input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1], policy?: ResiliencePolicy, fetchImpl?: typeof fetch): Promise<Response>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helper interno para as chamadas HTTP de saída do authkit-client (OIDC
|
|
3
|
+
* discovery, JWKS e token endpoints).
|
|
4
|
+
*
|
|
5
|
+
* Por padrão é um passthrough puro de `fetch` — ZERO mudança de comportamento.
|
|
6
|
+
* Quando uma {@link ResiliencePolicy} é fornecida (via config do client), a
|
|
7
|
+
* chamada `fetch` roda dentro de `policy.execute(() => fetch(...))`, ganhando
|
|
8
|
+
* timeout/retry/circuit-breaker conforme a política composta.
|
|
9
|
+
*
|
|
10
|
+
* A política é tipada ESTRUTURALMENTE de propósito: o authkit-client NÃO importa
|
|
11
|
+
* `@adonis-agora/resilience` em runtime. O consumidor passa o resultado de
|
|
12
|
+
* `wrap(...)` (de @adonis-agora/resilience) e o duck-typing casa com a interface.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* `fetch` envolvido por uma política de resiliência OPCIONAL.
|
|
16
|
+
*
|
|
17
|
+
* - Sem `policy`: chama `fetch(input, init)` diretamente (passthrough).
|
|
18
|
+
* - Com `policy`: roda `policy.execute(() => fetch(input, init))`.
|
|
19
|
+
*
|
|
20
|
+
* `fetchImpl` permite injetar um fetch fake nos testes.
|
|
21
|
+
*/
|
|
22
|
+
export function resilientFetch(input, init, policy, fetchImpl = fetch) {
|
|
23
|
+
if (!policy)
|
|
24
|
+
return fetchImpl(input, init);
|
|
25
|
+
return policy.execute(() => fetchImpl(input, init));
|
|
26
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Identity } from '@adonis-agora/authkit-core';
|
|
2
|
+
/** Model Lucid mínimo que o mirror precisa (só `updateOrCreate`). */
|
|
3
|
+
interface LucidModelLike {
|
|
4
|
+
updateOrCreate(search: Record<string, unknown>, payload: Record<string, unknown>): Promise<any>;
|
|
5
|
+
}
|
|
6
|
+
export interface LucidMirrorOptions {
|
|
7
|
+
/**
|
|
8
|
+
* Campos a sincronizar das claims do id_token a cada login (além do id). Default:
|
|
9
|
+
* `{ email }`. Ex.: `(i) => ({ email: i.email, fullName: i.profile?.name ?? null })`.
|
|
10
|
+
*/
|
|
11
|
+
sync?: (identity: Identity) => Record<string, unknown>;
|
|
12
|
+
/** Relações Lucid a pré-carregar no usuário resolvido (ex.: `['roles']`). */
|
|
13
|
+
preload?: string[];
|
|
14
|
+
/**
|
|
15
|
+
* Injeta `identity.globalRoles` numa propriedade NÃO persistida do usuário (p/ policies).
|
|
16
|
+
* `true` → `globalRoles`; string → nome custom da propriedade.
|
|
17
|
+
*/
|
|
18
|
+
injectGlobalRoles?: boolean | string;
|
|
19
|
+
/** Coluna que casa com `identity.userId`. Default: `id`. */
|
|
20
|
+
idColumn?: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Factory do padrão "espelho local" de `resolveUser`: faz `updateOrCreate` do usuário
|
|
24
|
+
* de domínio a partir das claims do id_token, opcionalmente pré-carrega relações e
|
|
25
|
+
* injeta os papéis globais. Substitui o `resolveUser` que cada app reescrevia.
|
|
26
|
+
*
|
|
27
|
+
* ```ts
|
|
28
|
+
* resolveUser: lucidMirror(AppUser, {
|
|
29
|
+
* sync: (i) => ({ email: i.email, fullName: i.profile?.name ?? null }),
|
|
30
|
+
* preload: ['roles'],
|
|
31
|
+
* injectGlobalRoles: true,
|
|
32
|
+
* })
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* Escape hatch: continue passando uma função própria em `resolveUser` quando precisar.
|
|
36
|
+
*/
|
|
37
|
+
export declare function lucidMirror(Model: LucidModelLike, options?: LucidMirrorOptions): (identity: Identity) => Promise<unknown>;
|
|
38
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Factory do padrão "espelho local" de `resolveUser`: faz `updateOrCreate` do usuário
|
|
3
|
+
* de domínio a partir das claims do id_token, opcionalmente pré-carrega relações e
|
|
4
|
+
* injeta os papéis globais. Substitui o `resolveUser` que cada app reescrevia.
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* resolveUser: lucidMirror(AppUser, {
|
|
8
|
+
* sync: (i) => ({ email: i.email, fullName: i.profile?.name ?? null }),
|
|
9
|
+
* preload: ['roles'],
|
|
10
|
+
* injectGlobalRoles: true,
|
|
11
|
+
* })
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* Escape hatch: continue passando uma função própria em `resolveUser` quando precisar.
|
|
15
|
+
*/
|
|
16
|
+
export function lucidMirror(Model, options = {}) {
|
|
17
|
+
const idColumn = options.idColumn ?? 'id';
|
|
18
|
+
return async (identity) => {
|
|
19
|
+
const synced = options.sync ? options.sync(identity) : { email: identity.email };
|
|
20
|
+
const user = await Model.updateOrCreate({ [idColumn]: identity.userId }, { [idColumn]: identity.userId, ...synced });
|
|
21
|
+
if (options.preload) {
|
|
22
|
+
for (const relation of options.preload) {
|
|
23
|
+
await user.load(relation);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (options.injectGlobalRoles) {
|
|
27
|
+
const prop = typeof options.injectGlobalRoles === 'string' ? options.injectGlobalRoles : 'globalRoles';
|
|
28
|
+
user[prop] = identity.globalRoles ?? [];
|
|
29
|
+
}
|
|
30
|
+
return user;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { HttpContext } from '@adonisjs/core/http';
|
|
2
|
+
import type { NextFn } from '@adonisjs/core/types/http';
|
|
3
|
+
export interface AuthMiddlewareOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Papéis aceitos. Passa se o usuário tiver QUALQUER um deles como role global
|
|
6
|
+
* (claim do IdP) OU como app role (`resolveAppRoles`). Vazio/ausente → só exige login.
|
|
7
|
+
*/
|
|
8
|
+
roles?: string[];
|
|
9
|
+
/** Para onde redirecionar quando não autenticado. Default: `/auth/login`. */
|
|
10
|
+
redirectTo?: string;
|
|
11
|
+
/** Para onde redirecionar quando autenticado mas sem o papel exigido. Default: `/unauthorized`. */
|
|
12
|
+
unauthorizedRedirect?: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Middleware de autenticação pronto do AuthKit: exige sessão válida (`ctx.auth.check()`)
|
|
16
|
+
* e, opcionalmente, papéis (global OU app). Substitui o middleware de auth que cada
|
|
17
|
+
* consumidor reescrevia à mão.
|
|
18
|
+
*
|
|
19
|
+
* ```ts
|
|
20
|
+
* // start/kernel.ts
|
|
21
|
+
* export const middleware = router.named({
|
|
22
|
+
* auth: () => import('@adonis-agora/authkit-client/auth_middleware'),
|
|
23
|
+
* })
|
|
24
|
+
* // uso: .use(middleware.auth({ roles: ['ADVISOR'], redirectTo: '/auth/login' }))
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export default class AuthkitAuthMiddleware {
|
|
28
|
+
handle(ctx: HttpContext, next: NextFn, options?: AuthMiddlewareOptions): Promise<any>;
|
|
29
|
+
}
|