@adonis-agora/authkit-server 0.51.0 → 0.52.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.
@@ -1,5 +1,5 @@
1
1
  <!doctype html>
2
- <html lang="{{ locale ?? 'en' }}"><head><meta charset="utf-8"><title>{{ t('otp_unlock.page_title') }}</title>
2
+ <html lang="pt-br"><head><meta charset="utf-8"><title>{{ t('otp_unlock.page_title') }}</title>
3
3
  @include('authkit::partials/styles')
4
4
  </head>
5
5
  <body class="min-h-screen bg-gray-100 flex items-center justify-center p-4">
@@ -0,0 +1,21 @@
1
+ <!doctype html>
2
+ <html lang="pt-br"><head><meta charset="utf-8"><title>{{ t('session_expired.page_title') }} — {{ brand && brand.appName ? brand.appName : t('common.app_fallback') }}</title>
3
+ @include('authkit::partials/styles')
4
+ </head>
5
+ <body class="min-h-screen flex items-center justify-center bg-gray-50 p-4">
6
+ <div class="w-full max-w-sm bg-white p-8 rounded-2xl shadow-xl ring-1 ring-black/5 text-center">
7
+ <div class="mb-4 flex justify-center">
8
+ <span class="inline-flex h-12 w-12 items-center justify-center rounded-full bg-amber-100 text-amber-600">
9
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
10
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
11
+ </svg>
12
+ </span>
13
+ </div>
14
+ <h1 class="text-xl font-semibold text-gray-900">{{ t('session_expired.title') }}</h1>
15
+ <p class="mt-3 text-sm text-gray-600">{{ t('session_expired.body') }}</p>
16
+ <a href="{{ loginUrl ?? '/account/login' }}"
17
+ class="mt-6 inline-block rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white transition hover:bg-gray-700">
18
+ {{ t('session_expired.login_link') }}
19
+ </a>
20
+ </div>
21
+ </body></html>
@@ -613,6 +613,30 @@ export interface ResolvedWebauthnConfig {
613
613
  * `rpName` cai no `fallbackName` (branding/mfaIssuer) quando ausente.
614
614
  */
615
615
  export declare function resolveWebauthn(issuer: string, fallbackName: string, input?: WebauthnConfigInput): ResolvedWebauthnConfig;
616
+ /**
617
+ * Recuperação da sessão de interaction OIDC perdida (expirada, cookie velho, F5
618
+ * tardio, restart do servidor). Perder essa sessão é um caso NORMAL: em vez de
619
+ * vazar o `SessionNotFound` cru do `oidc-provider`, o authkit RECUPERA.
620
+ */
621
+ export interface InteractionRecoveryConfigInput {
622
+ /**
623
+ * - `'screen'` (default): renderiza a tela themeável `session-expired` (view
624
+ * Edge built-in `session-expired`, ou a página React do host quando listada
625
+ * no allowlist do `inertiaRenderer`). Props: `{ loginUrl, brand }`.
626
+ * - `'redirect'`: responde 302 para `redirectTo` (recomeço limpo do login).
627
+ */
628
+ mode?: 'screen' | 'redirect';
629
+ /**
630
+ * Destino do link "voltar ao login" (screen) / do 302 (redirect). Default:
631
+ * `getAccountLoginUrl()` (respeita `accountLoginUrl`). NÃO aponte para uma URL
632
+ * de interaction — evita loop de redirect.
633
+ */
634
+ redirectTo?: string;
635
+ }
636
+ export interface ResolvedInteractionRecoveryConfig {
637
+ mode: 'screen' | 'redirect';
638
+ redirectTo?: string;
639
+ }
616
640
  export interface AuthServerConfigInput {
617
641
  issuer: string;
618
642
  adapter: AdapterFactory;
@@ -670,6 +694,13 @@ export interface AuthServerConfigInput {
670
694
  * `undefined` e o controller chamava `render(ctx, ...)` diretamente.
671
695
  */
672
696
  render?: AuthHostRenderer;
697
+ /**
698
+ * Recuperação graciosa da sessão de interaction OIDC perdida. Default:
699
+ * `{ mode: 'screen' }` — renderiza a tela themeável `session-expired`. Use
700
+ * `{ mode: 'redirect' }` para um 302 ao login. Ver
701
+ * {@link InteractionRecoveryConfigInput}.
702
+ */
703
+ interactionRecovery?: InteractionRecoveryConfigInput;
673
704
  /** Configuração de branding por cliente. */
674
705
  branding?: BrandingConfig;
675
706
  /** Internacionalização das telas. Default: pt-BR embutido (zero config). */
@@ -899,6 +930,8 @@ export interface ResolvedServerConfig {
899
930
  /** Destino default pós-confirmação do console (sudo mode) e demais fallbacks de conta. Default: '/account/security'. */
900
931
  accountHome?: string;
901
932
  render?: AuthHostRenderer;
933
+ /** Recuperação de sessão de interaction perdida resolvida (default `{ mode: 'screen' }`). */
934
+ interactionRecovery: ResolvedInteractionRecoveryConfig;
902
935
  branding?: BrandingConfig;
903
936
  social?: AuthSocialConfig;
904
937
  patIntrospectionSecret?: string;
@@ -331,6 +331,12 @@ export function defineConfig(config) {
331
331
  // sem o peer opcional instalado (só quebraria se a request realmente
332
332
  // chegasse sem Edge configurado, exatamente como hoje).
333
333
  render: config.render ?? edgeRenderer(),
334
+ // Recuperação de sessão de interaction perdida: default gracioso 'screen'
335
+ // (tela themeável `session-expired`); 'redirect' opt-in para 302 ao login.
336
+ interactionRecovery: {
337
+ mode: config.interactionRecovery?.mode ?? 'screen',
338
+ redirectTo: config.interactionRecovery?.redirectTo,
339
+ },
334
340
  branding: config.branding,
335
341
  social: config.social,
336
342
  patIntrospectionSecret: config.patIntrospectionSecret,
@@ -40,17 +40,31 @@ export default class AuthInteractionController {
40
40
  * passo login. Sem isto, os re-renders (erro de senha, magic link enviado, lockout…) mandam
41
41
  * `authMethods` undefined → a view volta ao default (senha ligada), ignorando a config.
42
42
  * `passkeyFirst` depende da conta (resolvido no fluxo principal); aqui cobrimos senha + magic link.
43
+ *
44
+ * `otpEnabled` (disponibilidade do login por OTP: config ligada E store com a
45
+ * capacidade) também sai daqui — é um FATO de disponibilidade de método de login,
46
+ * como `magicLinkAvailable`. Assim TODO render do passo login (identifier, seletor
47
+ * choose-first, magicLinkSent, erro) carrega a flag POR CONSTRUÇÃO: o seletor
48
+ * precisa dela para oferecer a opção "código" ANTES do envio do magic link.
49
+ *
50
+ * `runtimeSettings` pode ser passado já resolvido (ex.: o `show()` já resolveu para
51
+ * maintenance) para evitar uma segunda resolução; ausente, resolve sob demanda.
43
52
  */
44
- async #loginMethods(ctx, cfg) {
45
- const runtimeSettings = await getRuntimeSettings(ctx);
53
+ async #loginMethods(ctx, cfg, runtimeSettings) {
54
+ const settings = runtimeSettings ?? (await getRuntimeSettings(ctx));
46
55
  const magicLinkCapableConfig = cfg.passwordless.magicLink && supportsMagicLink(cfg.accountStore);
47
- const authMethods = await resolveEffectiveAuthMethods(runtimeSettings, {
56
+ const authMethods = await resolveEffectiveAuthMethods(settings, {
48
57
  configuredSocialProviders: cfg.social?.providers ?? [],
49
58
  magicLinkCapable: magicLinkCapableConfig,
50
59
  passkeyCapable: false,
51
60
  configOverrides: cfg.authMethods,
52
61
  });
53
- return { authMethods, magicLinkAvailable: authMethods.magicLink && magicLinkCapableConfig };
62
+ const otpEnabled = cfg.login.otp.enabled && supportsOtpLogin(cfg.accountStore);
63
+ return {
64
+ authMethods,
65
+ magicLinkAvailable: authMethods.magicLink && magicLinkCapableConfig,
66
+ otpEnabled,
67
+ };
54
68
  }
55
69
  async show(ctx) {
56
70
  const service = await ctx.containerResolver.make('authkit.server');
@@ -102,36 +116,32 @@ export default class AuthInteractionController {
102
116
  });
103
117
  }
104
118
  const email = ctx.session.get(SESSION_KEY);
105
- // Resolve auth methods (capabilities: magic link, passkey-first, social providers).
106
- // Capabilities are account-independent at this stage for the identifier step.
107
- const magicLinkCapableConfig = cfg.passwordless.magicLink && supportsMagicLink(cfg.accountStore);
108
- const configuredSocialProviders = cfg.social?.providers ?? [];
109
- const authMethodsSettings = runtimeSettingsForMaintenance;
110
- const authMethods = await resolveEffectiveAuthMethods(authMethodsSettings, {
111
- configuredSocialProviders,
112
- magicLinkCapable: magicLinkCapableConfig,
113
- passkeyCapable: false, // passkey-first depends on account — resolved in step 2
114
- configOverrides: cfg.authMethods,
115
- });
119
+ // Métodos de login efetivos (magic link, OTP, social) — account-independent
120
+ // neste ponto (passkey-first depende da conta, resolvido no step 2). Reusa o
121
+ // helper compartilhado por TODOS os renders do passo login, para que
122
+ // `authMethods`, `magicLinkAvailable` e `otpEnabled` saiam POR CONSTRUÇÃO
123
+ // (o seletor choose-first precisa de `otpEnabled` para oferecer "código").
124
+ // Passa o runtimeSettings já resolvido para maintenance (sem 2ª resolução).
125
+ const loginMethods = await this.#loginMethods(ctx, cfg, runtimeSettingsForMaintenance);
126
+ const authMethods = loginMethods.authMethods;
116
127
  if (!email) {
117
128
  // Step 1: identifier (email only).
118
129
  // Resolve registration enabled to hide "create account" link when closed.
119
130
  const registrationEnabled = await resolveEffectiveRegistration(cfg.registration?.enabled ?? true, runtimeSettingsForMaintenance);
120
131
  return render(ctx, 'login', {
132
+ ...loginMethods,
121
133
  uid: details.uid,
122
134
  csrfToken: ctx.request.csrfToken,
123
135
  step: 'identifier',
124
136
  registrationEnabled,
125
137
  brand,
126
- authMethods,
127
138
  });
128
139
  }
129
140
  // Step 2: password — look up user for personalisation (enumeration-safe: always show step 2)
130
141
  const acc = await cfg.accountStore.findByEmail(email);
131
142
  const account = acc ? { fullName: acc.name ?? null, globalRoles: acc.globalRoles ?? [] } : null;
132
- // Passwordless: magic link disponível se ligado E o store suporta E auth_methods permite.
143
+ // Passwordless: magic link disponível vem do helper (`loginMethods`).
133
144
  // Passkey-first disponível se ligado, o store suporta, a conta tem passkeys E auth_methods permite.
134
- const magicLinkAvailable = authMethods.magicLink && magicLinkCapableConfig;
135
145
  const passkeyFirstCapable = cfg.passwordless.passkeyFirst && !!acc && (await this.hasPasskeys(cfg, acc.id));
136
146
  const passkeyFirstAvailable = authMethods.passkey && passkeyFirstCapable;
137
147
  // Effective bot protection: may be overridden at runtime via auth_settings.
@@ -139,16 +149,15 @@ export default class AuthInteractionController {
139
149
  // Session policy: resolve para exibir o checkbox "manter conectado".
140
150
  const sessionPolicyForShow = await resolveEffectiveSessionPolicy(runtimeSettingsForMaintenance, cfg.ttl?.session ? Math.ceil(cfg.ttl.session / 3600) : undefined);
141
151
  return render(ctx, 'login', {
152
+ ...loginMethods,
142
153
  uid: details.uid,
143
154
  csrfToken: ctx.request.csrfToken,
144
155
  step: 'password',
145
156
  email,
146
157
  account,
147
158
  brand,
148
- magicLinkAvailable,
149
159
  passkeyFirstAvailable,
150
160
  botProtection: effectiveBot?.on.includes('login') ? effectiveBot.widget : undefined,
151
- authMethods,
152
161
  rememberEnabled: sessionPolicyForShow.rememberEnabled,
153
162
  rememberDays: sessionPolicyForShow.rememberDays,
154
163
  });
@@ -657,6 +666,8 @@ export default class AuthInteractionController {
657
666
  }
658
667
  }
659
668
  // Resposta uniforme (não vaza existência de conta).
669
+ // `otpEnabled` vem do spread de `#loginMethods` (mesmo valor do `otpEnabled`
670
+ // local usado acima para decidir a emissão) — não re-declarado aqui.
660
671
  return render(ctx, 'login', {
661
672
  ...(await this.#loginMethods(ctx, cfg)),
662
673
  uid,
@@ -666,7 +677,6 @@ export default class AuthInteractionController {
666
677
  account: null,
667
678
  brand,
668
679
  magicLinkSent: true,
669
- otpEnabled,
670
680
  // Prop da tela: qual sub-view do estado `magicLinkSent` mostrar —
671
681
  // 'code' (só o campo de código), 'link' (só o aviso de link) ou 'both'
672
682
  // (ambos, quando o host não escolheu canal). Back-compat: ausente = 'both'.
@@ -764,6 +774,8 @@ export default class AuthInteractionController {
764
774
  maxAttempts: cfg.login.otp.maxAttempts,
765
775
  });
766
776
  // Re-render da tela "link enviado" com o campo de código + erro localizado.
777
+ // `otpEnabled` vem do spread de `#loginMethods` (aqui, após a guarda acima,
778
+ // vale `true`) — não re-declarado inline.
767
779
  const renderOtpError = async (messageKey) => render(ctx, 'login', {
768
780
  ...(await this.#loginMethods(ctx, cfg)),
769
781
  uid,
@@ -773,7 +785,6 @@ export default class AuthInteractionController {
773
785
  account: null,
774
786
  brand,
775
787
  magicLinkSent: true,
776
- otpEnabled: true,
777
788
  magicChannel: magicChannelProp(channel),
778
789
  otpError: translate(cfg.messages, messageKey),
779
790
  });
@@ -474,6 +474,10 @@ export declare const DEFAULT_MESSAGES: {
474
474
  'maintenance.title': string;
475
475
  'maintenance.default_message': string;
476
476
  'maintenance.admin_login_note': string;
477
+ 'session_expired.page_title': string;
478
+ 'session_expired.title': string;
479
+ 'session_expired.body': string;
480
+ 'session_expired.login_link': string;
477
481
  'password.policy.min_length': string;
478
482
  'password.policy.uppercase': string;
479
483
  'password.policy.lowercase': string;
@@ -1178,6 +1182,10 @@ export declare const PT_BR_MESSAGES: {
1178
1182
  'maintenance.title': string;
1179
1183
  'maintenance.default_message': string;
1180
1184
  'maintenance.admin_login_note': string;
1185
+ 'session_expired.page_title': string;
1186
+ 'session_expired.title': string;
1187
+ 'session_expired.body': string;
1188
+ 'session_expired.login_link': string;
1181
1189
  'password.policy.min_length': string;
1182
1190
  'password.policy.uppercase': string;
1183
1191
  'password.policy.lowercase': string;
@@ -509,6 +509,11 @@ export const DEFAULT_MESSAGES = {
509
509
  'maintenance.title': 'Under Maintenance',
510
510
  'maintenance.default_message': 'The service is temporarily unavailable for maintenance. Please try again shortly.',
511
511
  'maintenance.admin_login_note': 'If you are an administrator, you may still log in to manage the system.',
512
+ // Sessão de login (interaction OIDC) expirada/perdida — recuperação graciosa.
513
+ 'session_expired.page_title': 'Session expired',
514
+ 'session_expired.title': 'Your session expired',
515
+ 'session_expired.body': 'Your login session expired or could not be found. This can happen if you left the page open too long. Please start again.',
516
+ 'session_expired.login_link': 'Back to login',
512
517
  // Política de senha (validação ao definir uma senha nova) + vazamento (HIBP) + histórico + expiração.
513
518
  'password.policy.min_length': 'Password must be at least {min} characters long.',
514
519
  'password.policy.uppercase': 'Password must contain at least one uppercase letter.',
@@ -1290,6 +1295,11 @@ export const PT_BR_MESSAGES = {
1290
1295
  'maintenance.title': 'Em manutenção',
1291
1296
  'maintenance.default_message': 'O serviço está temporariamente indisponível para manutenção. Tente novamente em breve.',
1292
1297
  'maintenance.admin_login_note': 'Se você é administrador, ainda pode entrar para gerenciar o sistema.',
1298
+ // Sessão de login (interaction OIDC) expirada/perdida — recuperação graciosa.
1299
+ 'session_expired.page_title': 'Sessão expirada',
1300
+ 'session_expired.title': 'Sua sessão expirou',
1301
+ 'session_expired.body': 'Sua sessão de login expirou ou não foi encontrada. Isso pode acontecer se a página ficou aberta por muito tempo. Recomece o login.',
1302
+ 'session_expired.login_link': 'Voltar ao login',
1293
1303
  // Política de senha (validação ao definir uma senha nova) + vazamento (HIBP) + histórico + expiração.
1294
1304
  'password.policy.min_length': 'A senha deve ter no mínimo {min} caracteres.',
1295
1305
  'password.policy.uppercase': 'A senha deve conter ao menos uma letra maiúscula.',
@@ -0,0 +1,55 @@
1
+ import { Exception } from '@adonisjs/core/exceptions';
2
+ import type { HttpContext } from '@adonisjs/core/http';
3
+ /**
4
+ * Recuperação graciosa da sessão de interaction OIDC perdida.
5
+ *
6
+ * Quando a sessão de interaction do `oidc-provider` está expirada/perdida
7
+ * (cookie velho, F5 tardio depois do TTL, restart do servidor que limpou o
8
+ * store efêmero), `provider.interactionDetails()` lança um `SessionNotFound`
9
+ * — subclasse de `InvalidRequest` (`error: 'invalid_request'`). Sem tratamento
10
+ * isso vaza para o usuário como um erro cru no meio do login.
11
+ *
12
+ * Perder a sessão de interaction é um caso NORMAL, não um bug do host. Por isso
13
+ * o authkit RECUPERA por padrão: renderiza a tela themeável `session-expired`
14
+ * (default) ou redireciona para o login (opt-in via `interactionRecovery`).
15
+ *
16
+ * O ponto de captura é ÚNICO — `createInteractionActions().details/consent`
17
+ * (ver `src/provider/interaction_actions.ts`) embrulham `interactionDetails`
18
+ * e convertem o `SessionNotFound` nesta exceção. TODOS os handlers de
19
+ * interaction (magic link, OTP, identifier, passkeys, MFA, consent…) passam
20
+ * por lá, então nenhum precisa de try/catch próprio.
21
+ */
22
+ /**
23
+ * Discriminador ROBUSTO de "sessão de interaction perdida".
24
+ *
25
+ * Não faz match por string de mensagem (frágil). Usa o NOME DA CLASSE do erro:
26
+ * o `oidc-provider` seta `this.name = this.constructor.name` na base
27
+ * `OIDCProviderError`, então toda instância de `SessionNotFound` carrega
28
+ * `name === 'SessionNotFound'`. Outros `invalid_request` legítimos (base
29
+ * `InvalidRequest`) têm `name === 'InvalidRequest'` — nunca colidem.
30
+ */
31
+ export declare function isInteractionSessionLost(err: unknown): boolean;
32
+ /**
33
+ * Exceção self-handling (contrato do `@adonisjs/http-server`: se o erro tem um
34
+ * método `handle`, o exception handler do host delega para ele). Assim a
35
+ * recuperação roda de forma centralizada, sem depender de o host customizar o
36
+ * `app/exceptions/handler.ts`.
37
+ */
38
+ export declare class InteractionSessionLostException extends Exception {
39
+ static status: number;
40
+ static code: string;
41
+ constructor();
42
+ handle(_error: this, ctx: HttpContext): Promise<unknown>;
43
+ }
44
+ /**
45
+ * Executa a estratégia de recuperação configurada em `interactionRecovery`:
46
+ *
47
+ * - `mode: 'screen'` (default): renderiza a view `session-expired` (Edge
48
+ * built-in, ou a página React do host quando listada no allowlist do
49
+ * `inertiaRenderer`). Recomeço limpo por um link "voltar ao login".
50
+ * - `mode: 'redirect'`: 302 para `redirectTo` (default: `accountLoginUrl`).
51
+ *
52
+ * NÃO cria loop de redirect: o destino default é o login do console de conta
53
+ * (fluxo separado, com sua própria sessão), nunca uma URL de interaction.
54
+ */
55
+ export declare function recoverLostInteraction(ctx: HttpContext): Promise<unknown>;
@@ -0,0 +1,91 @@
1
+ import { Exception } from '@adonisjs/core/exceptions';
2
+ import { getAccountLoginUrl } from './account_login_url.js';
3
+ import { brandFor } from './branding.js';
4
+ /**
5
+ * Recuperação graciosa da sessão de interaction OIDC perdida.
6
+ *
7
+ * Quando a sessão de interaction do `oidc-provider` está expirada/perdida
8
+ * (cookie velho, F5 tardio depois do TTL, restart do servidor que limpou o
9
+ * store efêmero), `provider.interactionDetails()` lança um `SessionNotFound`
10
+ * — subclasse de `InvalidRequest` (`error: 'invalid_request'`). Sem tratamento
11
+ * isso vaza para o usuário como um erro cru no meio do login.
12
+ *
13
+ * Perder a sessão de interaction é um caso NORMAL, não um bug do host. Por isso
14
+ * o authkit RECUPERA por padrão: renderiza a tela themeável `session-expired`
15
+ * (default) ou redireciona para o login (opt-in via `interactionRecovery`).
16
+ *
17
+ * O ponto de captura é ÚNICO — `createInteractionActions().details/consent`
18
+ * (ver `src/provider/interaction_actions.ts`) embrulham `interactionDetails`
19
+ * e convertem o `SessionNotFound` nesta exceção. TODOS os handlers de
20
+ * interaction (magic link, OTP, identifier, passkeys, MFA, consent…) passam
21
+ * por lá, então nenhum precisa de try/catch próprio.
22
+ */
23
+ /**
24
+ * Discriminador ROBUSTO de "sessão de interaction perdida".
25
+ *
26
+ * Não faz match por string de mensagem (frágil). Usa o NOME DA CLASSE do erro:
27
+ * o `oidc-provider` seta `this.name = this.constructor.name` na base
28
+ * `OIDCProviderError`, então toda instância de `SessionNotFound` carrega
29
+ * `name === 'SessionNotFound'`. Outros `invalid_request` legítimos (base
30
+ * `InvalidRequest`) têm `name === 'InvalidRequest'` — nunca colidem.
31
+ */
32
+ export function isInteractionSessionLost(err) {
33
+ if (!err || typeof err !== 'object')
34
+ return false;
35
+ const name = err.name;
36
+ if (name === 'SessionNotFound')
37
+ return true;
38
+ // Fallback defensivo: alguns transpilers/minificadores podem mexer no `name`;
39
+ // o nome do construtor é a fonte primária de `name` no oidc-provider.
40
+ const ctorName = err.constructor?.name;
41
+ return ctorName === 'SessionNotFound';
42
+ }
43
+ /**
44
+ * Exceção self-handling (contrato do `@adonisjs/http-server`: se o erro tem um
45
+ * método `handle`, o exception handler do host delega para ele). Assim a
46
+ * recuperação roda de forma centralizada, sem depender de o host customizar o
47
+ * `app/exceptions/handler.ts`.
48
+ */
49
+ export class InteractionSessionLostException extends Exception {
50
+ static status = 400;
51
+ static code = 'E_AUTHKIT_INTERACTION_SESSION_LOST';
52
+ constructor() {
53
+ super('A sessão de login expirou ou não foi encontrada. Recomece o login.', {
54
+ status: 400,
55
+ code: 'E_AUTHKIT_INTERACTION_SESSION_LOST',
56
+ });
57
+ }
58
+ async handle(_error, ctx) {
59
+ return recoverLostInteraction(ctx);
60
+ }
61
+ }
62
+ /**
63
+ * Executa a estratégia de recuperação configurada em `interactionRecovery`:
64
+ *
65
+ * - `mode: 'screen'` (default): renderiza a view `session-expired` (Edge
66
+ * built-in, ou a página React do host quando listada no allowlist do
67
+ * `inertiaRenderer`). Recomeço limpo por um link "voltar ao login".
68
+ * - `mode: 'redirect'`: 302 para `redirectTo` (default: `accountLoginUrl`).
69
+ *
70
+ * NÃO cria loop de redirect: o destino default é o login do console de conta
71
+ * (fluxo separado, com sua própria sessão), nunca uma URL de interaction.
72
+ */
73
+ export async function recoverLostInteraction(ctx) {
74
+ const service = await ctx.containerResolver.make('authkit.server');
75
+ const cfg = service.config;
76
+ const recovery = cfg.interactionRecovery ?? { mode: 'screen' };
77
+ const loginUrl = recovery.redirectTo ?? getAccountLoginUrl();
78
+ if (recovery.mode === 'redirect') {
79
+ return ctx.response.redirect(loginUrl);
80
+ }
81
+ // mode === 'screen': tela themeável. Brand best-effort — a sessão perdida não
82
+ // carrega o client_id, então usamos o brand default (sem cliente).
83
+ const render = cfg.render;
84
+ const brand = cfg.branding ? brandFor(cfg.branding, undefined, undefined) : undefined;
85
+ if (!render) {
86
+ // Sem renderer configurado — degrada para redirect (nunca 500).
87
+ return ctx.response.redirect(loginUrl);
88
+ }
89
+ ctx.response.status(400);
90
+ return render(ctx, 'session-expired', { loginUrl, brand });
91
+ }
@@ -45,7 +45,7 @@ export interface InertiaRendererOptions {
45
45
  * O `(string & {})` mantém o tipo aberto: telas custom (ou de versões mais
46
46
  * novas da lib) continuam aceitas sem erro de compilação.
47
47
  */
48
- export type AuthkitScreen = 'login' | 'signup' | 'consent' | 'forgot' | 'reset' | 'verify-email' | 'mfa-challenge' | 'otp-unlock' | 'maintenance' | 'account/login' | 'account/tokens' | 'account/mfa' | 'account/security' | 'account/apps' | 'account/orgs' | 'account/confirm' | 'account/email-confirmed' | (string & {});
48
+ export type AuthkitScreen = 'login' | 'signup' | 'consent' | 'forgot' | 'reset' | 'verify-email' | 'mfa-challenge' | 'otp-unlock' | 'maintenance' | 'session-expired' | 'account/login' | 'account/tokens' | 'account/mfa' | 'account/security' | 'account/apps' | 'account/orgs' | 'account/confirm' | 'account/email-confirmed' | (string & {});
49
49
  /**
50
50
  * Renderer do seam para hosts Inertia/React.
51
51
  *
@@ -1,8 +1,27 @@
1
+ import { InteractionSessionLostException, isInteractionSessionLost, } from '../host/interaction_recovery.js';
2
+ /**
3
+ * Choke point ÚNICO de `interactionDetails`: converte o `SessionNotFound` do
4
+ * `oidc-provider` (sessão de interaction perdida — caso NORMAL) na exceção
5
+ * self-handling `InteractionSessionLostException`, que recupera graciosamente
6
+ * (tela `session-expired` ou redirect) em vez de vazar um erro cru. Todo outro
7
+ * erro propaga inalterado. Usado por `details` e por `consent`, então nenhum
8
+ * handler de interaction precisa de try/catch próprio.
9
+ */
10
+ async function interactionDetailsOrRecover(provider, ctx) {
11
+ try {
12
+ return await provider.interactionDetails(ctx.request.request, ctx.response.response);
13
+ }
14
+ catch (err) {
15
+ if (isInteractionSessionLost(err))
16
+ throw new InteractionSessionLostException();
17
+ throw err;
18
+ }
19
+ }
1
20
  /** Lógica de interaction (login/consent) sobre o provider. Testável com um provider fake. */
2
21
  export function createInteractionActions(provider, deps) {
3
22
  return {
4
23
  async details(ctx) {
5
- return provider.interactionDetails(ctx.request.request, ctx.response.response);
24
+ return interactionDetailsOrRecover(provider, ctx);
6
25
  },
7
26
  async login(ctx, { email, password }) {
8
27
  if (!deps.verifyCredentials) {
@@ -31,7 +50,7 @@ export function createInteractionActions(provider, deps) {
31
50
  return { ok: true };
32
51
  },
33
52
  async consent(ctx) {
34
- const details = await provider.interactionDetails(ctx.request.request, ctx.response.response);
53
+ const details = await interactionDetailsOrRecover(provider, ctx);
35
54
  const grant = new provider.Grant({
36
55
  accountId: details.session.accountId,
37
56
  clientId: details.params.client_id,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adonis-agora/authkit-server",
3
- "version": "0.51.0",
3
+ "version": "0.52.0",
4
4
  "description": "AdonisJS OIDC/OAuth2 provider (Identity Provider) toolkit: ejectable auth server with sessions, rate-limiting, MFA/TOTP, audit log, federated logout and OpenTelemetry metrics.",
5
5
  "license": "MIT",
6
6
  "author": "dudousxd",