@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Davi de Carvalho
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# @adonis-agora/authkit-client
|
|
2
|
+
|
|
3
|
+
Adapter consumidor OpenID Connect para AdonisJS — integra um app como client do
|
|
4
|
+
Authorization Server (`@adonis-agora/authkit-server`), validando o ID token localmente por JWKS.
|
|
5
|
+
|
|
6
|
+
## Instalação
|
|
7
|
+
```bash
|
|
8
|
+
node ace add @adonis-agora/authkit-client
|
|
9
|
+
# ou: pnpm add @adonis-agora/authkit-client && node ace configure @adonis-agora/authkit-client
|
|
10
|
+
```
|
|
11
|
+
O `configure` publica `config/authkit_client.ts`, o controller `app/controllers/oidc_session_controller.ts`
|
|
12
|
+
(login/callback/logout) e registra o provider + o middleware `authkit_middleware`.
|
|
13
|
+
|
|
14
|
+
## Rotas de sessão
|
|
15
|
+
```ts
|
|
16
|
+
// start/routes.ts
|
|
17
|
+
import OidcSessionController from '#controllers/oidc_session_controller'
|
|
18
|
+
router.get('/auth/login', [OidcSessionController, 'login'])
|
|
19
|
+
router.get('/auth/callback', [OidcSessionController, 'callback'])
|
|
20
|
+
router.post('/auth/logout', [OidcSessionController, 'logout'])
|
|
21
|
+
```
|
|
22
|
+
`AUTHKIT_REDIRECT_URI` deve apontar para a rota de callback.
|
|
23
|
+
|
|
24
|
+
## Uso por request
|
|
25
|
+
`ctx.auth` é populado pelo `authkit_middleware`:
|
|
26
|
+
```ts
|
|
27
|
+
const identity = await ctx.auth.getIdentity() // claims OIDC validadas (ou null)
|
|
28
|
+
const user = await ctx.auth.getUser() // model do app (via resolveUser)
|
|
29
|
+
ctx.auth.hasGlobalRole('ADMIN') // síncrono, das claims
|
|
30
|
+
await ctx.auth.hasAppRole('COORDINATOR') // via resolveAppRoles
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Resolução de sessão
|
|
34
|
+
`resolvers.jwt({ tokenSource })` valida o **ID token** (JWT) por JWKS:
|
|
35
|
+
- `tokenSource: 'session'` (default) — lê o token set do `@adonisjs/session`.
|
|
36
|
+
- `tokenSource: 'bearer'` — lê do header `Authorization` (SPA/API).
|
|
37
|
+
|
|
38
|
+
## Topologias de banco
|
|
39
|
+
A identidade (`ctx.auth.identity`) sempre vem do **token validado** e independe da
|
|
40
|
+
topologia de banco. O que muda é como `resolveUser` obtém o model do app.
|
|
41
|
+
|
|
42
|
+
### Mesmo banco + schemas
|
|
43
|
+
O app lê um model Lucid local mapeado pra `auth.users` (FK cross-schema):
|
|
44
|
+
```ts
|
|
45
|
+
// config/authkit_client.ts
|
|
46
|
+
resolveUser: async (identity) => {
|
|
47
|
+
// FK cross-schema: app.users.auth_user_id -> auth.users.id
|
|
48
|
+
return AppUser.query().where('authUserId', identity.userId).firstOrFail()
|
|
49
|
+
},
|
|
50
|
+
resolveAppRoles: async (identity) => {
|
|
51
|
+
const u = await AppUser.findByOrFail('authUserId', identity.userId)
|
|
52
|
+
return u.related('roles').query().then((rs) => rs.map((r) => r.name))
|
|
53
|
+
},
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Bancos separados
|
|
57
|
+
Sem FK cross-schema possível. Como o IdP emite um **ID token "gordo"**
|
|
58
|
+
(email/name/roles nas claims), o caminho recomendado é claims-only:
|
|
59
|
+
```ts
|
|
60
|
+
import { identityToUser } from '@adonis-agora/authkit-client'
|
|
61
|
+
|
|
62
|
+
resolveUser: identityToUser, // { id, email, name?, avatarUrl?, globalRoles }
|
|
63
|
+
```
|
|
64
|
+
Pra dados além do que o token carrega, use o resolver de userinfo (busca no
|
|
65
|
+
endpoint `${issuer}/me` com o access token; faz fallback pra claims sem token):
|
|
66
|
+
```ts
|
|
67
|
+
import { createUserinfoResolver } from '@adonis-agora/authkit-client'
|
|
68
|
+
|
|
69
|
+
resolveUser: createUserinfoResolver({ issuer: env.get('AUTHKIT_ISSUER') }),
|
|
70
|
+
// ou: createUserinfoResolver({ userinfoEndpoint: 'https://idp/me' })
|
|
71
|
+
```
|
|
72
|
+
Em ambos os casos, `resolveAppRoles` **sempre consulta o banco do próprio app**:
|
|
73
|
+
```ts
|
|
74
|
+
resolveAppRoles: async (identity) => {
|
|
75
|
+
const roles = await AppRole.query().where('authUserId', identity.userId)
|
|
76
|
+
return roles.map((r) => r.name)
|
|
77
|
+
},
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Ordem de middleware (importante)
|
|
81
|
+
O middleware do `@adonisjs/session` DEVE rodar ANTES do `authkit_middleware` no stack `router`
|
|
82
|
+
(o resolver lê o token da session). Garanta essa ordem em `start/kernel.ts`.
|
|
83
|
+
|
|
84
|
+
## Observabilidade (opcional)
|
|
85
|
+
O `Authenticator` registra métricas de resolução (`authkit.resolve.duration` e
|
|
86
|
+
`authkit.resolve.errors`) quando um `MetricsRecorder` é injetado. As métricas são emitidas via
|
|
87
|
+
OpenTelemetry **quando** `@opentelemetry/api` está instalado; sem ele a agregação é no-op.
|
|
88
|
+
O comportamento por request não muda — observabilidade é puramente aditiva.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { stubsRoot } from '../stubs/main.js';
|
|
2
|
+
export async function configure(command) {
|
|
3
|
+
const codemods = await command.createCodemods();
|
|
4
|
+
await codemods.makeUsingStub(stubsRoot, 'config/authkit_client.stub', {});
|
|
5
|
+
await codemods.makeUsingStub(stubsRoot, 'controllers/oidc_session_controller.stub', {});
|
|
6
|
+
await codemods.updateRcFile((rc) => rc.addProvider('@adonis-agora/authkit-client/authkit_client_provider'));
|
|
7
|
+
await codemods.registerMiddleware('router', [{ path: '@adonis-agora/authkit-client/authkit_middleware' }]);
|
|
8
|
+
await codemods.defineEnvValidations({
|
|
9
|
+
leadingComment: 'Variáveis do @adonis-agora/authkit-client (OIDC client)',
|
|
10
|
+
variables: {
|
|
11
|
+
AUTHKIT_ISSUER: `Env.schema.string({ format: 'url', tld: false })`,
|
|
12
|
+
AUTHKIT_CLIENT_ID: `Env.schema.string()`,
|
|
13
|
+
AUTHKIT_CLIENT_SECRET: `Env.schema.string.optional()`,
|
|
14
|
+
AUTHKIT_REDIRECT_URI: `Env.schema.string({ format: 'url', tld: false })`,
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
}
|
package/build/index.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export { defineConfig, resolvers } from "./src/define_config.js";
|
|
2
|
+
export { Authenticator } from "./src/authenticator.js";
|
|
3
|
+
export { identityToUser, createUserinfoResolver, } from "./src/user_resolvers.js";
|
|
4
|
+
export type { ClaimsUser, ResolveUserContext, UserinfoResolverOptions, } from "./src/user_resolvers.js";
|
|
5
|
+
export { buildAuthorizeUrl, buildEndSessionUrl, exchangeCode, exchangeToken, refreshTokens, generatePkce, } from "./src/oidc_login.js";
|
|
6
|
+
export type { EndSessionParams, RefreshParams } from "./src/oidc_login.js";
|
|
7
|
+
export { discoverEndpoints, conventionEndpoints } from "./src/discovery.js";
|
|
8
|
+
export type { OidcEndpoints, DiscoverOptions } from "./src/discovery.js";
|
|
9
|
+
export { resilientFetch } from "./src/http/resilient_fetch.js";
|
|
10
|
+
export type { ResiliencePolicy } from "./src/http/resilient_fetch.js";
|
|
11
|
+
export { AuthkitClientManager } from "./providers/authkit_client_provider.js";
|
|
12
|
+
export type { ResolvedClientConfig, ClientConfigInput, BackchannelLogoutCallback, BackchannelLogoutInput, } from "./src/define_config.js";
|
|
13
|
+
export { lucidRevocationStore, DEFAULT_REVOCATION_TABLE, } from "./src/revocation/revocation_store.js";
|
|
14
|
+
export type { RevocationStore, LucidRevocationStoreOptions, } from "./src/revocation/revocation_store.js";
|
|
15
|
+
export { default as BackchannelRevocationMiddleware } from "./src/middleware/backchannel_revocation_middleware.js";
|
|
16
|
+
export { lucidMirror } from "./src/lucid_mirror.js";
|
|
17
|
+
export type { LucidMirrorOptions } from "./src/lucid_mirror.js";
|
|
18
|
+
export { registerOidcClient } from "./src/register_oidc_client.js";
|
|
19
|
+
export type { RegisterOidcClientOptions, PostLoginRedirects, } from "./src/register_oidc_client.js";
|
|
20
|
+
export { validateLogoutToken, InvalidLogoutTokenError, InMemorySessionIndex, BACKCHANNEL_LOGOUT_EVENT, } from "./src/backchannel_logout.js";
|
|
21
|
+
export type { ValidateLogoutTokenOptions, ValidatedLogoutToken, SessionIndex, SessionIndexEntry, } from "./src/backchannel_logout.js";
|
|
22
|
+
export { generateDpopKeyPair, createDpopProof, dpopJwkThumbprint, } from "./src/dpop.js";
|
|
23
|
+
export type { DpopKeyPair, CreateDpopProofInput } from "./src/dpop.js";
|
|
24
|
+
export { verifyJwtAccessToken, clearJwksCache, } from "./src/verify_access_token.js";
|
|
25
|
+
export type { VerifyJwtAccessTokenOptions, JwtAccessTokenClaims, } from "./src/verify_access_token.js";
|
|
26
|
+
export type { TokenSet } from "./src/types.js";
|
|
27
|
+
export type { Identity } from "@adonis-agora/authkit-core";
|
|
28
|
+
export { configure } from "./commands/configure.js";
|
package/build/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export { defineConfig, resolvers } from "./src/define_config.js";
|
|
2
|
+
export { Authenticator } from "./src/authenticator.js";
|
|
3
|
+
export { identityToUser, createUserinfoResolver, } from "./src/user_resolvers.js";
|
|
4
|
+
export { buildAuthorizeUrl, buildEndSessionUrl, exchangeCode, exchangeToken, refreshTokens, generatePkce, } from "./src/oidc_login.js";
|
|
5
|
+
export { discoverEndpoints, conventionEndpoints } from "./src/discovery.js";
|
|
6
|
+
export { resilientFetch } from "./src/http/resilient_fetch.js";
|
|
7
|
+
export { AuthkitClientManager } from "./providers/authkit_client_provider.js";
|
|
8
|
+
export { lucidRevocationStore, DEFAULT_REVOCATION_TABLE, } from "./src/revocation/revocation_store.js";
|
|
9
|
+
export { default as BackchannelRevocationMiddleware } from "./src/middleware/backchannel_revocation_middleware.js";
|
|
10
|
+
export { lucidMirror } from "./src/lucid_mirror.js";
|
|
11
|
+
export { registerOidcClient } from "./src/register_oidc_client.js";
|
|
12
|
+
export { validateLogoutToken, InvalidLogoutTokenError, InMemorySessionIndex, BACKCHANNEL_LOGOUT_EVENT, } from "./src/backchannel_logout.js";
|
|
13
|
+
export { generateDpopKeyPair, createDpopProof, dpopJwkThumbprint, } from "./src/dpop.js";
|
|
14
|
+
export { verifyJwtAccessToken, clearJwksCache, } from "./src/verify_access_token.js";
|
|
15
|
+
export { configure } from "./commands/configure.js";
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ApplicationService } from "@adonisjs/core/types";
|
|
2
|
+
import type { HttpContext } from "@adonisjs/core/http";
|
|
3
|
+
import { Authenticator } from "../src/authenticator.js";
|
|
4
|
+
import type { ResolvedClientConfig } from "../src/define_config.js";
|
|
5
|
+
/** Deps injetáveis (relógio/fetch) para testar `maybeRefresh` sem rede/tempo real. */
|
|
6
|
+
export interface RefreshDeps {
|
|
7
|
+
now?: () => number;
|
|
8
|
+
fetchImpl?: typeof fetch;
|
|
9
|
+
}
|
|
10
|
+
export declare class AuthkitClientManager {
|
|
11
|
+
#private;
|
|
12
|
+
private config;
|
|
13
|
+
constructor(config: ResolvedClientConfig);
|
|
14
|
+
get clientConfig(): ResolvedClientConfig;
|
|
15
|
+
/**
|
|
16
|
+
* Lê o id_token armazenado no token set da sessão. Usado pelo logout federado
|
|
17
|
+
* (RP-initiated) como `id_token_hint` ao redirecionar para `end_session_endpoint`.
|
|
18
|
+
*/
|
|
19
|
+
getIdToken(ctx: HttpContext): string | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* Renova o TokenSet da sessão proativamente se o access token estiver perto de
|
|
22
|
+
* expirar (dentro de REFRESH_SKEW_MS) e houver refresh_token. Persiste o TokenSet
|
|
23
|
+
* renovado — incluindo o refresh_token ROTACIONADO — de volta na sessão. Preserva
|
|
24
|
+
* o id_token / refresh_token anteriores quando o IdP não os reemite.
|
|
25
|
+
*
|
|
26
|
+
* Best-effort: qualquer falha (token revogado, IdP fora) é silenciosa — o resolver
|
|
27
|
+
* lida com a sessão expirada no fluxo normal. Chamado pelo middleware por request.
|
|
28
|
+
*/
|
|
29
|
+
maybeRefresh(ctx: HttpContext, deps?: RefreshDeps): Promise<void>;
|
|
30
|
+
/**
|
|
31
|
+
* Recebe o POST `application/x-www-form-urlencoded` de OIDC Back-Channel Logout. Lê o
|
|
32
|
+
* `logout_token` do body, valida (assinatura + regras do spec), consulta o SessionIndex
|
|
33
|
+
* (se houver) e invoca `onBackchannelLogout` p/ o host destruir as sessões locais.
|
|
34
|
+
*
|
|
35
|
+
* Retorna 200 em sucesso e 400 `{ error: 'invalid_request' }` em validação inválida.
|
|
36
|
+
* Sempre seta `Cache-Control: no-store` (exigência do spec).
|
|
37
|
+
*/
|
|
38
|
+
handleBackchannelLogout(ctx: HttpContext): Promise<unknown>;
|
|
39
|
+
/**
|
|
40
|
+
* Impersona `requestedSubject` via token-exchange (RFC 8693): guarda o token set
|
|
41
|
+
* atual e o substitui pelo do alvo. Encapsula o malabarismo de session keys que o
|
|
42
|
+
* consumidor fazia à mão. A AUTORIZAÇÃO (quem pode impersonar quem) é responsabilidade
|
|
43
|
+
* do chamador (ex.: Bouncer) ANTES de chamar este método.
|
|
44
|
+
*/
|
|
45
|
+
impersonate(ctx: HttpContext, requestedSubject: string): Promise<void>;
|
|
46
|
+
/** Encerra a impersonação restaurando o token set original. Retorna false se não havia impersonação. */
|
|
47
|
+
stopImpersonating(ctx: HttpContext): Promise<boolean>;
|
|
48
|
+
/** Indica se a request corrente está impersonando (p/ exibir banner na UI). */
|
|
49
|
+
isImpersonating(ctx: HttpContext): boolean;
|
|
50
|
+
createAuthenticator(ctx: HttpContext): Promise<Authenticator>;
|
|
51
|
+
}
|
|
52
|
+
declare module "@adonisjs/core/types" {
|
|
53
|
+
interface ContainerBindings {
|
|
54
|
+
"authkit.client": AuthkitClientManager;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export default class AuthkitClientProvider {
|
|
58
|
+
protected app: ApplicationService;
|
|
59
|
+
constructor(app: ApplicationService);
|
|
60
|
+
register(): void;
|
|
61
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { configProvider } from "@adonisjs/core";
|
|
2
|
+
import { RuntimeException } from "@adonisjs/core/exceptions";
|
|
3
|
+
import { Authenticator } from "../src/authenticator.js";
|
|
4
|
+
import { refreshTokens, exchangeToken } from "../src/oidc_login.js";
|
|
5
|
+
import { validateLogoutToken } from "../src/backchannel_logout.js";
|
|
6
|
+
/** Margem (ms) antes do `expiresAt` em que o access token é renovado proativamente. */
|
|
7
|
+
const REFRESH_SKEW_MS = 60_000;
|
|
8
|
+
export class AuthkitClientManager {
|
|
9
|
+
config;
|
|
10
|
+
#resolverCache;
|
|
11
|
+
constructor(config) {
|
|
12
|
+
this.config = config;
|
|
13
|
+
}
|
|
14
|
+
async #getResolver() {
|
|
15
|
+
if (!this.#resolverCache) {
|
|
16
|
+
this.#resolverCache = await this.config.resolverFactory.resolver({
|
|
17
|
+
issuer: this.config.issuer,
|
|
18
|
+
clientId: this.config.clientId,
|
|
19
|
+
clientSecret: this.config.clientSecret,
|
|
20
|
+
sessionKey: this.config.sessionKey,
|
|
21
|
+
globalRolesClaim: this.config.globalRolesClaim,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
return this.#resolverCache;
|
|
25
|
+
}
|
|
26
|
+
get clientConfig() {
|
|
27
|
+
return this.config;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Lê o id_token armazenado no token set da sessão. Usado pelo logout federado
|
|
31
|
+
* (RP-initiated) como `id_token_hint` ao redirecionar para `end_session_endpoint`.
|
|
32
|
+
*/
|
|
33
|
+
getIdToken(ctx) {
|
|
34
|
+
const tokenSet = ctx.session?.get(this.config.sessionKey);
|
|
35
|
+
return tokenSet?.idToken || undefined;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Renova o TokenSet da sessão proativamente se o access token estiver perto de
|
|
39
|
+
* expirar (dentro de REFRESH_SKEW_MS) e houver refresh_token. Persiste o TokenSet
|
|
40
|
+
* renovado — incluindo o refresh_token ROTACIONADO — de volta na sessão. Preserva
|
|
41
|
+
* o id_token / refresh_token anteriores quando o IdP não os reemite.
|
|
42
|
+
*
|
|
43
|
+
* Best-effort: qualquer falha (token revogado, IdP fora) é silenciosa — o resolver
|
|
44
|
+
* lida com a sessão expirada no fluxo normal. Chamado pelo middleware por request.
|
|
45
|
+
*/
|
|
46
|
+
async maybeRefresh(ctx, deps = {}) {
|
|
47
|
+
const session = ctx.session;
|
|
48
|
+
if (!session)
|
|
49
|
+
return;
|
|
50
|
+
const tokenSet = session.get(this.config.sessionKey);
|
|
51
|
+
if (!tokenSet?.refreshToken)
|
|
52
|
+
return;
|
|
53
|
+
const now = deps.now ?? Date.now;
|
|
54
|
+
// Sem expiresAt conhecido, não renova proativamente (evita refresh a cada request).
|
|
55
|
+
if (!tokenSet.expiresAt || tokenSet.expiresAt - now() > REFRESH_SKEW_MS)
|
|
56
|
+
return;
|
|
57
|
+
try {
|
|
58
|
+
const next = await refreshTokens({
|
|
59
|
+
issuer: this.config.issuer,
|
|
60
|
+
clientId: this.config.clientId,
|
|
61
|
+
clientSecret: this.config.clientSecret,
|
|
62
|
+
refreshToken: tokenSet.refreshToken,
|
|
63
|
+
fetchImpl: deps.fetchImpl,
|
|
64
|
+
resilience: this.config.resilience,
|
|
65
|
+
});
|
|
66
|
+
session.put(this.config.sessionKey, {
|
|
67
|
+
idToken: next.idToken || tokenSet.idToken,
|
|
68
|
+
accessToken: next.accessToken,
|
|
69
|
+
refreshToken: next.refreshToken ?? tokenSet.refreshToken,
|
|
70
|
+
expiresAt: next.expiresAt,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// Renovação falhou — deixa o TokenSet como está; o resolver decide a sessão.
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Recebe o POST `application/x-www-form-urlencoded` de OIDC Back-Channel Logout. Lê o
|
|
79
|
+
* `logout_token` do body, valida (assinatura + regras do spec), consulta o SessionIndex
|
|
80
|
+
* (se houver) e invoca `onBackchannelLogout` p/ o host destruir as sessões locais.
|
|
81
|
+
*
|
|
82
|
+
* Retorna 200 em sucesso e 400 `{ error: 'invalid_request' }` em validação inválida.
|
|
83
|
+
* Sempre seta `Cache-Control: no-store` (exigência do spec).
|
|
84
|
+
*/
|
|
85
|
+
async handleBackchannelLogout(ctx) {
|
|
86
|
+
const { request, response } = ctx;
|
|
87
|
+
response.header("Cache-Control", "no-store");
|
|
88
|
+
const token = request.input("logout_token");
|
|
89
|
+
if (!token || typeof token !== "string") {
|
|
90
|
+
return response.badRequest({ error: "invalid_request" });
|
|
91
|
+
}
|
|
92
|
+
let validated;
|
|
93
|
+
try {
|
|
94
|
+
validated = await validateLogoutToken(token, {
|
|
95
|
+
issuer: this.config.issuer,
|
|
96
|
+
clientId: this.config.clientId,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return response.badRequest({ error: "invalid_request" });
|
|
101
|
+
}
|
|
102
|
+
// Atualiza o índice local (se configurado) — revoga as sessões mapeadas.
|
|
103
|
+
const index = this.config.sessionIndex;
|
|
104
|
+
if (index) {
|
|
105
|
+
if (validated.sid)
|
|
106
|
+
await index.revokeBySid(validated.sid);
|
|
107
|
+
else if (validated.sub)
|
|
108
|
+
await index.revokeBySub(validated.sub);
|
|
109
|
+
}
|
|
110
|
+
// Delega ao host a destruição efetiva das sessões locais.
|
|
111
|
+
await this.config.onBackchannelLogout?.({
|
|
112
|
+
sid: validated.sid,
|
|
113
|
+
sub: validated.sub,
|
|
114
|
+
});
|
|
115
|
+
return {};
|
|
116
|
+
}
|
|
117
|
+
/** Chave de sessão onde o token set ORIGINAL fica guardado durante a impersonação. */
|
|
118
|
+
get #impersonatorKey() {
|
|
119
|
+
return `${this.config.sessionKey}_impersonator`;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Impersona `requestedSubject` via token-exchange (RFC 8693): guarda o token set
|
|
123
|
+
* atual e o substitui pelo do alvo. Encapsula o malabarismo de session keys que o
|
|
124
|
+
* consumidor fazia à mão. A AUTORIZAÇÃO (quem pode impersonar quem) é responsabilidade
|
|
125
|
+
* do chamador (ex.: Bouncer) ANTES de chamar este método.
|
|
126
|
+
*/
|
|
127
|
+
async impersonate(ctx, requestedSubject) {
|
|
128
|
+
const session = ctx.session;
|
|
129
|
+
const current = session?.get(this.config.sessionKey);
|
|
130
|
+
if (!current?.accessToken) {
|
|
131
|
+
throw new Error("Sem sessão ativa para impersonar a partir dela");
|
|
132
|
+
}
|
|
133
|
+
const impersonated = await exchangeToken({
|
|
134
|
+
issuer: this.config.issuer,
|
|
135
|
+
clientId: this.config.clientId,
|
|
136
|
+
clientSecret: this.config.clientSecret,
|
|
137
|
+
subjectToken: current.accessToken,
|
|
138
|
+
requestedSubject,
|
|
139
|
+
resilience: this.config.resilience,
|
|
140
|
+
});
|
|
141
|
+
session.put(this.#impersonatorKey, current);
|
|
142
|
+
session.put(this.config.sessionKey, impersonated);
|
|
143
|
+
}
|
|
144
|
+
/** Encerra a impersonação restaurando o token set original. Retorna false se não havia impersonação. */
|
|
145
|
+
async stopImpersonating(ctx) {
|
|
146
|
+
const session = ctx.session;
|
|
147
|
+
const original = session?.get(this.#impersonatorKey);
|
|
148
|
+
if (!original)
|
|
149
|
+
return false;
|
|
150
|
+
session.put(this.config.sessionKey, original);
|
|
151
|
+
session.forget(this.#impersonatorKey);
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
/** Indica se a request corrente está impersonando (p/ exibir banner na UI). */
|
|
155
|
+
isImpersonating(ctx) {
|
|
156
|
+
return Boolean(ctx.session?.get(this.#impersonatorKey));
|
|
157
|
+
}
|
|
158
|
+
async createAuthenticator(ctx) {
|
|
159
|
+
const resolver = await this.#getResolver();
|
|
160
|
+
const sessionKey = this.config.sessionKey;
|
|
161
|
+
return new Authenticator(ctx, {
|
|
162
|
+
resolver,
|
|
163
|
+
resolveUser: this.config.resolveUser,
|
|
164
|
+
resolveAppRoles: this.config.resolveAppRoles,
|
|
165
|
+
getAccessToken: () => {
|
|
166
|
+
const tokenSet = ctx.session?.get(sessionKey);
|
|
167
|
+
return tokenSet?.accessToken;
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
export default class AuthkitClientProvider {
|
|
173
|
+
app;
|
|
174
|
+
constructor(app) {
|
|
175
|
+
this.app = app;
|
|
176
|
+
}
|
|
177
|
+
register() {
|
|
178
|
+
this.app.container.singleton("authkit.client", async () => {
|
|
179
|
+
const value = this.app.config.get("authkit_client");
|
|
180
|
+
const config = (await configProvider.resolve(this.app, value));
|
|
181
|
+
if (!config) {
|
|
182
|
+
throw new RuntimeException('Config inválido em "config/authkit_client.ts". Use defineConfig de @adonis-agora/authkit-client.');
|
|
183
|
+
}
|
|
184
|
+
return new AuthkitClientManager(config);
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { HttpContext } from "@adonisjs/core/http";
|
|
2
|
+
import type { Identity, SessionResolver } from "@adonis-agora/authkit-core";
|
|
3
|
+
import { type MetricsRecorder } from "@adonis-agora/authkit-core";
|
|
4
|
+
/** Contexto opcional repassado a `resolveUser` (extensão backward-compatible). */
|
|
5
|
+
export interface ResolveUserContext {
|
|
6
|
+
accessToken?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface AuthenticatorDeps {
|
|
9
|
+
resolver: SessionResolver;
|
|
10
|
+
resolveUser?: (identity: Identity, context: ResolveUserContext) => Promise<unknown>;
|
|
11
|
+
resolveAppRoles?: (identity: Identity) => Promise<string[]>;
|
|
12
|
+
/** lê o access token do token set da sessão (opcional; usado por resolvers userinfo) */
|
|
13
|
+
getAccessToken?: () => string | null | undefined;
|
|
14
|
+
}
|
|
15
|
+
export declare class Authenticator {
|
|
16
|
+
#private;
|
|
17
|
+
private ctx;
|
|
18
|
+
private deps;
|
|
19
|
+
constructor(ctx: HttpContext, deps: AuthenticatorDeps, recorder?: MetricsRecorder);
|
|
20
|
+
getIdentity(): Promise<Identity | null>;
|
|
21
|
+
get identity(): Identity | null;
|
|
22
|
+
authenticate(): Promise<Identity>;
|
|
23
|
+
check(): Promise<boolean>;
|
|
24
|
+
hasGlobalRole(role: string): boolean;
|
|
25
|
+
hasAppRole(role: string): Promise<boolean>;
|
|
26
|
+
getUser(): Promise<unknown>;
|
|
27
|
+
/** Resolve (e cacheia) os app roles da identidade corrente. [] sem identidade/resolver. */
|
|
28
|
+
getAppRoles(): Promise<string[]>;
|
|
29
|
+
/**
|
|
30
|
+
* Monta o objeto pronto para compartilhar com o frontend (ex.: Inertia share),
|
|
31
|
+
* casando com o `AuthSharedProps` que o `@adonis-agora/authkit-react` consome.
|
|
32
|
+
* `abilities` é a união de app roles + global roles (conveniente p/ policies/CASL).
|
|
33
|
+
* Retorna `null` quando não há sessão.
|
|
34
|
+
*/
|
|
35
|
+
toSharedProps(): Promise<{
|
|
36
|
+
user: unknown;
|
|
37
|
+
globalRoles: string[];
|
|
38
|
+
appRoles: string[];
|
|
39
|
+
abilities: string[];
|
|
40
|
+
} | null>;
|
|
41
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { AUTHKIT_METRICS, NoopRecorder, } from "@adonis-agora/authkit-core";
|
|
2
|
+
import { populateContext } from "./observability/context_bridge.js";
|
|
3
|
+
export class Authenticator {
|
|
4
|
+
ctx;
|
|
5
|
+
deps;
|
|
6
|
+
#identity = null;
|
|
7
|
+
#resolved = false;
|
|
8
|
+
#user;
|
|
9
|
+
#userResolved = false;
|
|
10
|
+
#appRoles = null;
|
|
11
|
+
#recorder;
|
|
12
|
+
constructor(ctx, deps, recorder = new NoopRecorder()) {
|
|
13
|
+
this.ctx = ctx;
|
|
14
|
+
this.deps = deps;
|
|
15
|
+
this.#recorder = recorder;
|
|
16
|
+
}
|
|
17
|
+
async getIdentity() {
|
|
18
|
+
if (!this.#resolved) {
|
|
19
|
+
const start = Date.now();
|
|
20
|
+
try {
|
|
21
|
+
this.#identity = await this.deps.resolver.resolve(this.ctx);
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
this.#recorder.increment(AUTHKIT_METRICS.resolveErrors);
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
this.#recorder.record(AUTHKIT_METRICS.resolveDuration, Date.now() - start);
|
|
29
|
+
}
|
|
30
|
+
this.#resolved = true;
|
|
31
|
+
// Ponte estrutural: popula o contexto do Agora quando há sessão de fato
|
|
32
|
+
// (best-effort, no-op sem o slot `@agora/context:set`). Apenas no caminho
|
|
33
|
+
// de sucesso — não força resolução para requests anônimas.
|
|
34
|
+
if (this.#identity)
|
|
35
|
+
populateContext(this.#identity);
|
|
36
|
+
}
|
|
37
|
+
return this.#identity;
|
|
38
|
+
}
|
|
39
|
+
get identity() {
|
|
40
|
+
return this.#identity;
|
|
41
|
+
}
|
|
42
|
+
async authenticate() {
|
|
43
|
+
const identity = await this.getIdentity();
|
|
44
|
+
if (!identity)
|
|
45
|
+
throw new Error("Not authenticated");
|
|
46
|
+
return identity;
|
|
47
|
+
}
|
|
48
|
+
async check() {
|
|
49
|
+
return (await this.getIdentity()) !== null;
|
|
50
|
+
}
|
|
51
|
+
hasGlobalRole(role) {
|
|
52
|
+
return this.#identity?.globalRoles.includes(role) ?? false;
|
|
53
|
+
}
|
|
54
|
+
async hasAppRole(role) {
|
|
55
|
+
const identity = await this.getIdentity();
|
|
56
|
+
if (!identity || !this.deps.resolveAppRoles)
|
|
57
|
+
return false;
|
|
58
|
+
if (this.#appRoles === null)
|
|
59
|
+
this.#appRoles = await this.deps.resolveAppRoles(identity);
|
|
60
|
+
return this.#appRoles.includes(role);
|
|
61
|
+
}
|
|
62
|
+
async getUser() {
|
|
63
|
+
if (this.#userResolved)
|
|
64
|
+
return this.#user;
|
|
65
|
+
const identity = await this.getIdentity();
|
|
66
|
+
if (identity && this.deps.resolveUser) {
|
|
67
|
+
const accessToken = this.deps.getAccessToken?.() ?? undefined;
|
|
68
|
+
this.#user = await this.deps.resolveUser(identity, { accessToken });
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
this.#user = null;
|
|
72
|
+
}
|
|
73
|
+
this.#userResolved = true;
|
|
74
|
+
return this.#user;
|
|
75
|
+
}
|
|
76
|
+
/** Resolve (e cacheia) os app roles da identidade corrente. [] sem identidade/resolver. */
|
|
77
|
+
async getAppRoles() {
|
|
78
|
+
const identity = await this.getIdentity();
|
|
79
|
+
if (!identity || !this.deps.resolveAppRoles)
|
|
80
|
+
return [];
|
|
81
|
+
if (this.#appRoles === null)
|
|
82
|
+
this.#appRoles = await this.deps.resolveAppRoles(identity);
|
|
83
|
+
return this.#appRoles;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Monta o objeto pronto para compartilhar com o frontend (ex.: Inertia share),
|
|
87
|
+
* casando com o `AuthSharedProps` que o `@adonis-agora/authkit-react` consome.
|
|
88
|
+
* `abilities` é a união de app roles + global roles (conveniente p/ policies/CASL).
|
|
89
|
+
* Retorna `null` quando não há sessão.
|
|
90
|
+
*/
|
|
91
|
+
async toSharedProps() {
|
|
92
|
+
const identity = await this.getIdentity();
|
|
93
|
+
if (!identity)
|
|
94
|
+
return null;
|
|
95
|
+
const user = await this.getUser();
|
|
96
|
+
const globalRoles = identity.globalRoles ?? [];
|
|
97
|
+
const appRoles = await this.getAppRoles();
|
|
98
|
+
return {
|
|
99
|
+
user,
|
|
100
|
+
globalRoles,
|
|
101
|
+
appRoles,
|
|
102
|
+
abilities: [...appRoles, ...globalRoles],
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { type JWTVerifyGetKey } from 'jose';
|
|
2
|
+
/** Event type da claim `events` exigido pelo OIDC Back-Channel Logout. */
|
|
3
|
+
export declare const BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
|
|
4
|
+
/** Erro tipado lançado quando o logout_token não satisfaz as regras do spec. */
|
|
5
|
+
export declare class InvalidLogoutTokenError extends Error {
|
|
6
|
+
constructor(message: string);
|
|
7
|
+
}
|
|
8
|
+
export interface ValidateLogoutTokenOptions {
|
|
9
|
+
/** Issuer esperado (deve bater com a claim `iss`). */
|
|
10
|
+
issuer: string;
|
|
11
|
+
/** clientId do RP; deve estar contido na claim `aud`. */
|
|
12
|
+
clientId: string;
|
|
13
|
+
/**
|
|
14
|
+
* `jwks_uri` do IdP. Usado para montar um remote JWKS quando `keys` não é
|
|
15
|
+
* fornecido. Default: `${issuer}/jwks` (rota padrão do oidc-provider).
|
|
16
|
+
*/
|
|
17
|
+
jwksUri?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Função de chave já resolvida (ex.: `createRemoteJWKSet` ou `createLocalJWKSet`).
|
|
20
|
+
* Tem precedência sobre `jwksUri`; útil em testes para injetar uma chave local.
|
|
21
|
+
*/
|
|
22
|
+
keys?: JWTVerifyGetKey;
|
|
23
|
+
/** Algoritmos aceitos. Default: assimétricos. */
|
|
24
|
+
algorithms?: string[];
|
|
25
|
+
}
|
|
26
|
+
/** Resultado da validação: as claims relevantes p/ localizar a sessão local. */
|
|
27
|
+
export interface ValidatedLogoutToken {
|
|
28
|
+
sid?: string;
|
|
29
|
+
sub?: string;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Valida um `logout_token` (JWT) recebido no Back-Channel Logout, conforme
|
|
33
|
+
* https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation.
|
|
34
|
+
*
|
|
35
|
+
* Verifica a assinatura contra o JWKS do IdP e aplica as regras do spec:
|
|
36
|
+
* - `iss` bate com o issuer configurado, `aud` inclui o clientId, `iat` presente;
|
|
37
|
+
* - claim `events` é um objeto contendo a chave do evento de back-channel logout;
|
|
38
|
+
* - possui ao menos `sid` ou `sub`;
|
|
39
|
+
* - NÃO contém a claim `nonce` (proibida no logout_token).
|
|
40
|
+
*
|
|
41
|
+
* Lança {@link InvalidLogoutTokenError} em qualquer violação.
|
|
42
|
+
*/
|
|
43
|
+
export declare function validateLogoutToken(token: string, opts: ValidateLogoutTokenOptions): Promise<ValidatedLogoutToken>;
|
|
44
|
+
/** Registro de uma sessão local indexada por sid/sub do OP. */
|
|
45
|
+
export interface SessionIndexEntry {
|
|
46
|
+
sid?: string;
|
|
47
|
+
sub: string;
|
|
48
|
+
sessionId: string;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Mapeia `sid`/`sub` do OP para os ids de sessão locais do RP. É a peça central do
|
|
52
|
+
* Back-Channel Logout: ao receber um logout_token, o handler consulta este índice
|
|
53
|
+
* para descobrir QUAIS sessões locais destruir.
|
|
54
|
+
*
|
|
55
|
+
* A implementação default ({@link InMemorySessionIndex}) é em memória — adequada para
|
|
56
|
+
* testes e instâncias únicas. Em PRODUÇÃO, hosts com múltiplas instâncias devem
|
|
57
|
+
* implementar esta interface sobre um store compartilhado (Redis/DB), já que o POST
|
|
58
|
+
* de logout pode chegar em uma instância diferente da que criou a sessão.
|
|
59
|
+
*/
|
|
60
|
+
export interface SessionIndex {
|
|
61
|
+
/** Registra o vínculo OP(sid/sub) -> sessão local. Chamado após o login. */
|
|
62
|
+
register(entry: SessionIndexEntry): Promise<void> | void;
|
|
63
|
+
/** Remove e retorna os sessionIds vinculados ao sid (sessão SSO específica). */
|
|
64
|
+
revokeBySid(sid: string): Promise<string[]> | string[];
|
|
65
|
+
/** Remove e retorna TODOS os sessionIds vinculados ao sub (todas as sessões do usuário). */
|
|
66
|
+
revokeBySub(sub: string): Promise<string[]> | string[];
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Implementação default em memória do {@link SessionIndex}. Mantém o mapeamento
|
|
70
|
+
* sid -> sessionId e sub -> Set<sessionId>. NÃO use em produção multi-instância.
|
|
71
|
+
*/
|
|
72
|
+
export declare class InMemorySessionIndex implements SessionIndex {
|
|
73
|
+
#private;
|
|
74
|
+
register(entry: SessionIndexEntry): void;
|
|
75
|
+
revokeBySid(sid: string): string[];
|
|
76
|
+
revokeBySub(sub: string): string[];
|
|
77
|
+
}
|