@adonis-agora/authkit-server 0.76.0 → 0.77.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.
Files changed (43) hide show
  1. package/README.md +145 -0
  2. package/build/host/views/mfa-challenge.edge +42 -3
  3. package/build/index.d.ts +9 -1
  4. package/build/index.js +5 -1
  5. package/build/src/accounts/account_store.d.ts +2 -0
  6. package/build/src/accounts/lucid_account_store.js +2 -0
  7. package/build/src/adapters/adapter_contract.d.ts +2 -0
  8. package/build/src/adapters/database_adapter.d.ts +1 -0
  9. package/build/src/adapters/database_adapter.js +26 -0
  10. package/build/src/adapters/redis_adapter.d.ts +1 -0
  11. package/build/src/adapters/redis_adapter.js +30 -0
  12. package/build/src/define_config.d.ts +24 -0
  13. package/build/src/define_config.js +8 -0
  14. package/build/src/host/controllers/account_security_controller.js +8 -4
  15. package/build/src/host/controllers/interaction_controller.d.ts +13 -1
  16. package/build/src/host/controllers/interaction_controller.js +352 -24
  17. package/build/src/host/custom_login.d.ts +36 -0
  18. package/build/src/host/custom_login.js +76 -0
  19. package/build/src/host/custom_login_completion.d.ts +4 -0
  20. package/build/src/host/custom_login_completion.js +33 -0
  21. package/build/src/host/custom_mfa.d.ts +77 -0
  22. package/build/src/host/custom_mfa.js +268 -0
  23. package/build/src/host/i18n.d.ts +4 -0
  24. package/build/src/host/i18n.js +10 -6
  25. package/build/src/host/idp_session_bridge.d.ts +5 -3
  26. package/build/src/host/idp_session_bridge.js +5 -3
  27. package/build/src/host/oidc_rp_guard.d.ts +5 -1
  28. package/build/src/host/oidc_rp_guard.js +15 -2
  29. package/build/src/host/persistent_rp_session.d.ts +8 -0
  30. package/build/src/host/persistent_rp_session.js +74 -0
  31. package/build/src/host/register_auth_host.js +12 -0
  32. package/build/src/host/whatsapp_code_sender.d.ts +20 -0
  33. package/build/src/host/whatsapp_code_sender.js +9 -0
  34. package/build/src/host/whatsapp_senders/http.d.ts +5 -0
  35. package/build/src/host/whatsapp_senders/http.js +33 -0
  36. package/build/src/host/whatsapp_senders/meta_whatsapp_code_sender.d.ts +20 -0
  37. package/build/src/host/whatsapp_senders/meta_whatsapp_code_sender.js +51 -0
  38. package/build/src/host/whatsapp_senders/whatsmiau_code_sender.d.ts +14 -0
  39. package/build/src/host/whatsapp_senders/whatsmiau_code_sender.js +36 -0
  40. package/build/src/provider/oidc_service.js +6 -2
  41. package/build/stubs/ui/react/pages/mfa-challenge.tsx +207 -37
  42. package/package.json +2 -1
  43. package/stubs/ui/react/pages/mfa-challenge.tsx +207 -37
@@ -0,0 +1,33 @@
1
+ import { AdminSessionsService } from './admin_sessions_service.js';
2
+ import { resolveRuntimeSettingsOrNoop } from './runtime_settings.js';
3
+ import { resolveEffectiveSessionPolicy } from './runtime_toggles.js';
4
+ /** Shared by direct custom completion and its TOTP/passkey continuation. */
5
+ export async function completeCustomLoginSession(ctx, accountId, extra, rememberRequested = false) {
6
+ const service = await ctx.containerResolver.make('authkit.server');
7
+ const settings = await resolveRuntimeSettingsOrNoop(ctx);
8
+ const policy = await resolveEffectiveSessionPolicy(settings, service.config.ttl?.session ? Math.ceil(service.config.ttl.session / 3600) : undefined);
9
+ const sessions = policy.singleSession ? new AdminSessionsService(service) : undefined;
10
+ const previous = sessions?.canList ? await sessions.listSessions(accountId) : [];
11
+ const details = sessions?.canList ? await service.interactions.details(ctx) : undefined;
12
+ const currentIdpSession = details?.session?.uid
13
+ ? await service.provider.Session.findByUid(details.session.uid)
14
+ : undefined;
15
+ const result = await service.interactions.completeLogin(ctx, accountId, {
16
+ ...extra,
17
+ remember: policy.rememberEnabled && rememberRequested,
18
+ });
19
+ const denied = result !== null && typeof result === 'object' && 'ok' in result && result.ok === false;
20
+ if (!denied && sessions?.canList && previous.length) {
21
+ const previousIds = new Set(previous.map((session) => session.id));
22
+ const current = await sessions.listSessions(accountId);
23
+ const created = current.find((session) => !previousIds.has(session.id));
24
+ const revoked = await sessions.revokeAllExcept(accountId, created?.id ?? currentIdpSession?.id ?? '__none__');
25
+ await service.config.audit?.record({
26
+ type: 'session.single_enforced',
27
+ accountId,
28
+ ip: ctx.request.ip(),
29
+ metadata: { revokedCount: revoked.sessions },
30
+ });
31
+ }
32
+ return result;
33
+ }
@@ -0,0 +1,77 @@
1
+ import type { HttpContext } from '@adonisjs/core/http';
2
+ import type { AuthAccount } from '../accounts/account_store.js';
3
+ export interface CustomMfaContext {
4
+ ctx: HttpContext;
5
+ account: AuthAccount;
6
+ accountId: string;
7
+ uid: string;
8
+ primaryMethod: string;
9
+ challengeId: string;
10
+ }
11
+ export interface CustomMfaField {
12
+ name: string;
13
+ label: string;
14
+ type?: 'text' | 'password';
15
+ inputMode?: 'numeric' | 'text';
16
+ autoComplete?: string;
17
+ }
18
+ export interface CustomMfaDescription {
19
+ label: string;
20
+ fields?: CustomMfaField[];
21
+ }
22
+ /** A host-defined enrolled factor. Verify and consume its proof within this challenge. */
23
+ export interface CustomMfaMethod {
24
+ /** Methods sharing the same credential/channel must share a factorId. */
25
+ readonly factorId?: string;
26
+ isEnabled(context: CustomMfaContext): Promise<boolean>;
27
+ describe(context: CustomMfaContext): Promise<CustomMfaDescription>;
28
+ begin?(context: CustomMfaContext): Promise<void>;
29
+ verify(context: CustomMfaContext): Promise<boolean>;
30
+ }
31
+ export type CustomMfaMethodConstructor = new (...args: never[]) => CustomMfaMethod;
32
+ export type CustomMfaMethodBinding = CustomMfaMethod | CustomMfaMethodConstructor;
33
+ export type CustomMfaMethods = Readonly<Record<string, CustomMfaMethodBinding>>;
34
+ export interface RuntimeMfaFlow {
35
+ uid: string;
36
+ accountId: string;
37
+ primaryMethod: string;
38
+ primaryFactorId: string;
39
+ challengeId: string;
40
+ /** Total distinct factors, including the authenticated primary factor. */
41
+ requiredFactors: number;
42
+ remember?: boolean;
43
+ passwordless?: boolean;
44
+ completed: Array<{
45
+ method: string;
46
+ factorId: string;
47
+ }>;
48
+ startedMethods: string[];
49
+ createdAt: number;
50
+ attempts: number;
51
+ }
52
+ export interface CustomMfaDescriptor extends CustomMfaDescription {
53
+ id: string;
54
+ factorId: string;
55
+ requiresBegin: boolean;
56
+ }
57
+ export declare function isCustomMfaMethodId(method: string): boolean;
58
+ export declare function initializeMfaFlow(ctx: HttpContext, input: Pick<RuntimeMfaFlow, 'uid' | 'accountId' | 'primaryMethod' | 'primaryFactorId' | 'requiredFactors' | 'remember' | 'passwordless'>): void;
59
+ export declare function clearMfaFlow(ctx: HttpContext): void;
60
+ export declare function getMfaFlow(ctx: HttpContext): RuntimeMfaFlow | null;
61
+ export declare function resolveCustomMfaMethods(ctx: HttpContext, accountId: string, uid: string, primaryMethod: string, challengeId: string): Promise<CustomMfaDescriptor[]>;
62
+ export declare function customMfaViewProps(ctx: HttpContext): Promise<{
63
+ customMfaMethods: Array<CustomMfaDescriptor & {
64
+ beginUrl: string;
65
+ verifyUrl: string;
66
+ started: boolean;
67
+ }>;
68
+ completedMfaMethods: string[];
69
+ requiredMfaFactors: number;
70
+ }>;
71
+ export declare function beginCustomMfa(ctx: HttpContext, method: string): Promise<void>;
72
+ export declare function verifyCustomMfa(ctx: HttpContext, method: string): Promise<boolean>;
73
+ /** Call only after a trusted verifier consumes a proof; this does not finish the login. */
74
+ export declare function recordVerifiedMfaFactor(ctx: HttpContext, method: string, factorId?: string): {
75
+ complete: boolean;
76
+ amr: string[];
77
+ };
@@ -0,0 +1,268 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { Exception } from '@adonisjs/core/exceptions';
3
+ const FLOW_KEY = 'authkit_mfa_flow';
4
+ const PENDING_KEY = 'authkit_mfa_pending';
5
+ const FLOW_TTL = 10 * 60 * 1000;
6
+ const MAX_ATTEMPTS = 5;
7
+ const RESERVED = new Set(['totp', 'webauthn', 'recovery', 'pwd', 'email', 'mfa']);
8
+ const isId = (value) => typeof value === 'string' && /^[a-z][a-z0-9:_-]{0,63}$/.test(value);
9
+ export function isCustomMfaMethodId(method) {
10
+ return isId(method) && !RESERVED.has(method);
11
+ }
12
+ function failure(message) {
13
+ return new Exception(message, { code: 'E_CUSTOM_MFA', status: 422 });
14
+ }
15
+ export function initializeMfaFlow(ctx, input) {
16
+ if (!input.uid ||
17
+ !input.accountId ||
18
+ !isId(input.primaryMethod) ||
19
+ !isId(input.primaryFactorId) ||
20
+ !Number.isInteger(input.requiredFactors) ||
21
+ input.requiredFactors < 2 ||
22
+ input.requiredFactors > 8) {
23
+ throw failure('Invalid MFA flow');
24
+ }
25
+ ctx.session.put(FLOW_KEY, {
26
+ uid: input.uid,
27
+ accountId: input.accountId,
28
+ primaryMethod: input.primaryMethod,
29
+ primaryFactorId: input.primaryFactorId,
30
+ requiredFactors: input.requiredFactors,
31
+ passwordless: input.passwordless ?? input.primaryMethod !== 'pwd',
32
+ ...(input.remember !== undefined ? { remember: input.remember === true } : {}),
33
+ challengeId: randomUUID(),
34
+ completed: [],
35
+ startedMethods: [],
36
+ createdAt: Date.now(),
37
+ attempts: 0,
38
+ });
39
+ }
40
+ export function clearMfaFlow(ctx) {
41
+ ctx.session.forget(FLOW_KEY);
42
+ }
43
+ function invalidateMfaFlow(ctx) {
44
+ clearMfaFlow(ctx);
45
+ for (const key of [PENDING_KEY, 'authkit_mfa_primary', 'authkit_mfa_custom_primary'])
46
+ ctx.session.forget(key);
47
+ }
48
+ function isRecord(value) {
49
+ return value !== null && typeof value === 'object';
50
+ }
51
+ export function getMfaFlow(ctx) {
52
+ const flow = ctx.session.get(FLOW_KEY);
53
+ if (flow === undefined || flow === null)
54
+ return null;
55
+ const now = Date.now();
56
+ if (!isRecord(flow) ||
57
+ flow.uid !== ctx.request.param('uid') ||
58
+ typeof flow.accountId !== 'string' ||
59
+ !flow.accountId ||
60
+ flow.accountId !== ctx.session.get(PENDING_KEY) ||
61
+ !isId(flow.primaryMethod) ||
62
+ !isId(flow.primaryFactorId) ||
63
+ typeof flow.challengeId !== 'string' ||
64
+ !/^[a-f0-9-]{36}$/.test(flow.challengeId) ||
65
+ typeof flow.createdAt !== 'number' ||
66
+ !Number.isFinite(flow.createdAt) ||
67
+ flow.createdAt > now ||
68
+ now - flow.createdAt >= FLOW_TTL ||
69
+ typeof flow.requiredFactors !== 'number' ||
70
+ !Number.isInteger(flow.requiredFactors) ||
71
+ flow.requiredFactors < 2 ||
72
+ flow.requiredFactors > 8 ||
73
+ typeof flow.attempts !== 'number' ||
74
+ !Number.isInteger(flow.attempts) ||
75
+ flow.attempts < 0 ||
76
+ flow.attempts >= MAX_ATTEMPTS ||
77
+ (flow.remember !== undefined && typeof flow.remember !== 'boolean') ||
78
+ !Array.isArray(flow.startedMethods) ||
79
+ !flow.startedMethods.every((id) => isCustomMfaMethodId(id)) ||
80
+ !Array.isArray(flow.completed) ||
81
+ !flow.completed.every((entry) => isRecord(entry) &&
82
+ isId(entry.method) &&
83
+ isId(entry.factorId) &&
84
+ entry.factorId !== flow.primaryFactorId) ||
85
+ new Set(flow.completed.map((entry) => entry.factorId)).size !== flow.completed.length) {
86
+ invalidateMfaFlow(ctx);
87
+ return null;
88
+ }
89
+ return flow;
90
+ }
91
+ function requireFlow(ctx) {
92
+ const flow = getMfaFlow(ctx);
93
+ if (!flow)
94
+ throw failure('Missing or expired MFA flow');
95
+ return flow;
96
+ }
97
+ function requireCurrentFlow(ctx, previous) {
98
+ const current = requireFlow(ctx);
99
+ if (current.challengeId !== previous.challengeId)
100
+ throw failure('MFA challenge changed');
101
+ return current;
102
+ }
103
+ async function contextFor(ctx, accountId, uid, primaryMethod, challengeId) {
104
+ const service = await ctx.containerResolver.make('authkit.server');
105
+ const details = await service.interactions.details(ctx);
106
+ if (details.uid !== uid || uid !== ctx.request.param('uid') || details.prompt.name !== 'login') {
107
+ throw failure('Invalid MFA interaction');
108
+ }
109
+ const account = await service.config.accountStore.findById(accountId);
110
+ if (!account || account.id !== accountId)
111
+ throw failure('Invalid MFA account');
112
+ return { ctx, account, accountId, uid, primaryMethod, challengeId };
113
+ }
114
+ async function resolveHandler(ctx, id) {
115
+ if (!isCustomMfaMethodId(id))
116
+ throw failure('Invalid custom MFA method');
117
+ const service = await ctx.containerResolver.make('authkit.server');
118
+ const config = service.config;
119
+ const methods = config.mfa?.methods;
120
+ if (!methods || !Object.hasOwn(methods, id))
121
+ throw failure('Unknown custom MFA method');
122
+ const binding = methods[id];
123
+ const handler = typeof binding === 'function' ? await ctx.containerResolver.make(binding) : binding;
124
+ if (!handler ||
125
+ typeof handler.isEnabled !== 'function' ||
126
+ typeof handler.describe !== 'function' ||
127
+ typeof handler.verify !== 'function' ||
128
+ (handler.begin !== undefined && typeof handler.begin !== 'function') ||
129
+ (handler.factorId !== undefined && !isId(handler.factorId))) {
130
+ throw failure('Invalid custom MFA method');
131
+ }
132
+ return handler;
133
+ }
134
+ /** Only explicit public descriptor fields are passed to templates. */
135
+ function descriptionProps(description) {
136
+ if (!description || typeof description.label !== 'string' || !description.label.trim()) {
137
+ throw failure('Invalid custom MFA method description');
138
+ }
139
+ if (description.fields !== undefined &&
140
+ (!Array.isArray(description.fields) || description.fields.length > 8)) {
141
+ throw failure('Invalid custom MFA method fields');
142
+ }
143
+ const fields = description.fields?.map((field) => {
144
+ if (!field ||
145
+ typeof field.name !== 'string' ||
146
+ !/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(field.name) ||
147
+ typeof field.label !== 'string' ||
148
+ (field.type !== undefined && !['text', 'password'].includes(field.type)) ||
149
+ (field.inputMode !== undefined && !['numeric', 'text'].includes(field.inputMode)) ||
150
+ (field.autoComplete !== undefined && typeof field.autoComplete !== 'string')) {
151
+ throw failure('Invalid custom MFA method fields');
152
+ }
153
+ return {
154
+ name: field.name,
155
+ label: field.label,
156
+ ...(field.type !== undefined ? { type: field.type } : {}),
157
+ ...(field.inputMode !== undefined ? { inputMode: field.inputMode } : {}),
158
+ ...(field.autoComplete !== undefined ? { autoComplete: field.autoComplete } : {}),
159
+ };
160
+ });
161
+ return { label: description.label, ...(fields ? { fields } : {}) };
162
+ }
163
+ export async function resolveCustomMfaMethods(ctx, accountId, uid, primaryMethod, challengeId) {
164
+ const service = await ctx.containerResolver.make('authkit.server');
165
+ const config = service.config;
166
+ const ids = Object.keys(config.mfa?.methods ?? {});
167
+ if (!ids.length)
168
+ return [];
169
+ const context = await contextFor(ctx, accountId, uid, primaryMethod, challengeId);
170
+ const descriptors = [];
171
+ for (const id of ids) {
172
+ const handler = await resolveHandler(ctx, id);
173
+ if (!(await handler.isEnabled(context)))
174
+ continue;
175
+ descriptors.push({
176
+ id,
177
+ factorId: handler.factorId ?? id,
178
+ requiresBegin: typeof handler.begin === 'function',
179
+ ...descriptionProps(await handler.describe(context)),
180
+ });
181
+ }
182
+ return descriptors;
183
+ }
184
+ export async function customMfaViewProps(ctx) {
185
+ const flow = requireFlow(ctx);
186
+ const descriptors = await resolveCustomMfaMethods(ctx, flow.accountId, flow.uid, flow.primaryMethod, flow.challengeId);
187
+ const used = new Set([flow.primaryFactorId, ...flow.completed.map((entry) => entry.factorId)]);
188
+ return {
189
+ customMfaMethods: descriptors
190
+ .filter((descriptor) => !used.has(descriptor.factorId))
191
+ .map((descriptor) => {
192
+ const base = `/auth/interaction/${encodeURIComponent(flow.uid)}/mfa/custom/${encodeURIComponent(descriptor.id)}`;
193
+ return {
194
+ ...descriptor,
195
+ beginUrl: `${base}/begin`,
196
+ verifyUrl: `${base}/verify`,
197
+ started: flow.startedMethods.includes(descriptor.id),
198
+ };
199
+ }),
200
+ completedMfaMethods: flow.completed.map((entry) => entry.method),
201
+ requiredMfaFactors: flow.requiredFactors,
202
+ };
203
+ }
204
+ async function activeHandler(ctx, method) {
205
+ const flow = requireFlow(ctx);
206
+ const context = await contextFor(ctx, flow.accountId, flow.uid, flow.primaryMethod, flow.challengeId);
207
+ const handler = await resolveHandler(ctx, method);
208
+ const factorId = handler.factorId ?? method;
209
+ if (factorId === flow.primaryFactorId ||
210
+ flow.completed.some((entry) => entry.factorId === factorId)) {
211
+ throw failure('MFA factor has already been authenticated');
212
+ }
213
+ if (!(await handler.isEnabled(context)))
214
+ throw failure('Custom MFA method is not enabled');
215
+ requireCurrentFlow(ctx, flow);
216
+ return { flow, context, handler };
217
+ }
218
+ export async function beginCustomMfa(ctx, method) {
219
+ const { flow, context, handler } = await activeHandler(ctx, method);
220
+ if (!handler.begin)
221
+ throw failure('Custom MFA method has no challenge initiation');
222
+ await handler.begin(context);
223
+ requireCurrentFlow(ctx, flow);
224
+ if (!flow.startedMethods.includes(method))
225
+ flow.startedMethods.push(method);
226
+ ctx.session.put(FLOW_KEY, flow);
227
+ }
228
+ export async function verifyCustomMfa(ctx, method) {
229
+ const { flow, context, handler } = await activeHandler(ctx, method);
230
+ if (handler.begin && !flow.startedMethods.includes(method))
231
+ throw failure('Custom MFA challenge has not started');
232
+ let verified;
233
+ try {
234
+ verified = (await handler.verify(context)) === true;
235
+ }
236
+ catch (error) {
237
+ requireCurrentFlow(ctx, flow);
238
+ failedProof(ctx, flow);
239
+ throw error;
240
+ }
241
+ requireCurrentFlow(ctx, flow);
242
+ if (!verified)
243
+ failedProof(ctx, flow);
244
+ return verified;
245
+ }
246
+ function failedProof(ctx, flow) {
247
+ flow.attempts++;
248
+ if (flow.attempts >= MAX_ATTEMPTS)
249
+ invalidateMfaFlow(ctx);
250
+ else
251
+ ctx.session.put(FLOW_KEY, flow);
252
+ }
253
+ /** Call only after a trusted verifier consumes a proof; this does not finish the login. */
254
+ export function recordVerifiedMfaFactor(ctx, method, factorId = method) {
255
+ const flow = requireFlow(ctx);
256
+ if (!isId(method) ||
257
+ !isId(factorId) ||
258
+ factorId === flow.primaryFactorId ||
259
+ flow.completed.some((entry) => entry.factorId === factorId)) {
260
+ throw failure('MFA factor has already been authenticated or is invalid');
261
+ }
262
+ flow.completed.push({ method, factorId });
263
+ ctx.session.put(FLOW_KEY, flow);
264
+ return {
265
+ complete: 1 + flow.completed.length >= flow.requiredFactors,
266
+ amr: [flow.primaryMethod, ...flow.completed.map((entry) => entry.method)],
267
+ };
268
+ }
@@ -86,6 +86,8 @@ export declare const DEFAULT_MESSAGES: {
86
86
  'mfa_challenge.page_title': string;
87
87
  'mfa_challenge.title': string;
88
88
  'mfa_challenge.intro': string;
89
+ 'mfa_challenge.start': string;
90
+ 'mfa_challenge.progress': string;
89
91
  'mfa_challenge.code_label': string;
90
92
  'mfa_challenge.submit': string;
91
93
  'mfa_challenge.recovery_summary': string;
@@ -836,6 +838,8 @@ export declare const PT_BR_MESSAGES: {
836
838
  'mfa_challenge.page_title': string;
837
839
  'mfa_challenge.title': string;
838
840
  'mfa_challenge.intro': string;
841
+ 'mfa_challenge.start': string;
842
+ 'mfa_challenge.progress': string;
839
843
  'mfa_challenge.code_label': string;
840
844
  'mfa_challenge.submit': string;
841
845
  'mfa_challenge.recovery_summary': string;
@@ -80,9 +80,11 @@ export const DEFAULT_MESSAGES = {
80
80
  'verify_email.invalid_title': 'Invalid link',
81
81
  'verify_email.invalid_body': 'The verification link is invalid or has already been used.',
82
82
  // Desafio de MFA no fluxo de login (mfa-challenge).
83
- 'mfa_challenge.page_title': 'Two-factor verification',
84
- 'mfa_challenge.title': 'Two-factor verification',
85
- 'mfa_challenge.intro': 'Open your authenticator app and enter the 6-digit code.',
83
+ 'mfa_challenge.page_title': 'Multi-factor verification',
84
+ 'mfa_challenge.title': 'Multi-factor verification',
85
+ 'mfa_challenge.intro': 'Verify your identity using an available method.',
86
+ 'mfa_challenge.start': 'Start verification',
87
+ 'mfa_challenge.progress': '{completed} of {required} factors verified.',
86
88
  'mfa_challenge.code_label': 'Code',
87
89
  'mfa_challenge.submit': 'Verify',
88
90
  'mfa_challenge.recovery_summary': 'Use a recovery code',
@@ -920,9 +922,11 @@ export const PT_BR_MESSAGES = {
920
922
  'verify_email.invalid_title': 'Link inválido',
921
923
  'verify_email.invalid_body': 'O link de verificação é inválido ou já foi utilizado.',
922
924
  // Desafio de MFA no fluxo de login (mfa-challenge).
923
- 'mfa_challenge.page_title': 'Verificação em duas etapas',
924
- 'mfa_challenge.title': 'Verificação em duas etapas',
925
- 'mfa_challenge.intro': 'Abra seu app autenticador e informe o código de 6 dígitos.',
925
+ 'mfa_challenge.page_title': 'Verificação multifator',
926
+ 'mfa_challenge.title': 'Verificação multifator',
927
+ 'mfa_challenge.intro': 'Verifique sua identidade usando um método disponível.',
928
+ 'mfa_challenge.start': 'Iniciar verificação',
929
+ 'mfa_challenge.progress': '{completed} de {required} fatores verificados.',
926
930
  'mfa_challenge.code_label': 'Código',
927
931
  'mfa_challenge.submit': 'Verificar',
928
932
  'mfa_challenge.recovery_summary': 'Usar um código de recuperação',
@@ -27,7 +27,10 @@ import type { HttpContext } from '@adonisjs/core/http';
27
27
  /** Chave da sessão Adonis com o `uid` da sessão do IdP que originou o console. */
28
28
  export declare const ACCOUNT_IDP_SESSION_KEY = "authkit_idp_session_uid";
29
29
  /** O que interessa de uma sessão do oidc-provider (instância do model `Session`). */
30
- interface IdpSession {
30
+ export interface IdpSession {
31
+ id: string;
32
+ transient?: boolean;
33
+ exp: number;
31
34
  uid: string;
32
35
  accountId?: string;
33
36
  destroy(): Promise<void>;
@@ -37,7 +40,7 @@ interface IdpSession {
37
40
  * provider faz: cookie `_session` assinado com as keys do provider, e o registro
38
41
  * no adapter, que já descarta expirados). `null` sem sessão logada.
39
42
  */
40
- export declare function readIdpSession(ctx: HttpContext, service: any): Promise<IdpSession | null>;
43
+ export declare function readIdpSession(ctx: HttpContext, service: any, strict?: boolean): Promise<IdpSession | null>;
41
44
  /**
42
45
  * Garante a sessão do console, aceitando a sessão do IdP quando configurado.
43
46
  * Devolve `true` quando o request tem (ou passou a ter) sessão de console.
@@ -52,4 +55,3 @@ export declare function ensureConsoleSession(ctx: HttpContext): Promise<boolean>
52
55
  * própria ponte e o "Sair" não teria efeito. No-op fora da ponte.
53
56
  */
54
57
  export declare function endBridgedIdpSession(ctx: HttpContext): Promise<void>;
55
- export {};
@@ -9,7 +9,7 @@ export const ACCOUNT_IDP_SESSION_KEY = 'authkit_idp_session_uid';
9
9
  * provider faz: cookie `_session` assinado com as keys do provider, e o registro
10
10
  * no adapter, que já descarta expirados). `null` sem sessão logada.
11
11
  */
12
- export async function readIdpSession(ctx, service) {
12
+ export async function readIdpSession(ctx, service, strict = false) {
13
13
  const provider = service?.provider;
14
14
  if (!provider?.Session || typeof provider.createContext !== 'function')
15
15
  return null;
@@ -21,7 +21,9 @@ export async function readIdpSession(ctx, service) {
21
21
  const session = (await provider.Session.find(id));
22
22
  return session?.accountId ? session : null;
23
23
  }
24
- catch {
24
+ catch (error) {
25
+ if (strict)
26
+ throw error;
25
27
  // Fail-safe: sem ponte (o guard segue para o login normal).
26
28
  return null;
27
29
  }
@@ -81,8 +83,8 @@ export async function ensureConsoleSession(ctx) {
81
83
  // o login do console faz.
82
84
  await ctx.session.regenerate();
83
85
  ctx.session.put(ACCOUNT_SESSION_KEY, account.id);
84
- ctx.session.put(ACCOUNT_IDP_SESSION_KEY, idp.uid);
85
86
  await syncAdonisAuthLogin(ctx, cfg, account);
87
+ ctx.session.put(ACCOUNT_IDP_SESSION_KEY, idp.uid);
86
88
  return true;
87
89
  }
88
90
  /**
@@ -4,6 +4,7 @@ import type { SessionUserProviderContract } from '@adonisjs/auth/types/session';
4
4
  import type { HttpContext } from '@adonisjs/core/http';
5
5
  import type { ConfigProvider } from '@adonisjs/core/types';
6
6
  import type { EmitterLike } from '@adonisjs/core/types/events';
7
+ import { type PersistentRpOptions } from './persistent_rp_session.js';
7
8
  type RealUser<UserProvider> = UserProvider extends SessionUserProviderContract<infer U> ? U : never;
8
9
  /**
9
10
  * Construtor do `E_UNAUTHORIZED_ACCESS` do `@adonisjs/auth` — a MESMA classe que
@@ -27,6 +28,8 @@ export declare function loadUnauthorizedAccess(guardFactoryName?: string): Promi
27
28
  export type OidcRpGuardOptions<UserProvider extends SessionUserProviderContract<unknown>> = {
28
29
  provider: UserProvider | ConfigProvider<UserProvider>;
29
30
  sessionKey?: string;
31
+ /** Same-host IdP: restore and renew remembered logins. Default: false. */
32
+ acceptIdpSession?: PersistentRpOptions['acceptIdpSession'];
30
33
  };
31
34
  export type OidcRpGuardEvents<User> = {
32
35
  'oidc_rp:login_succeeded': {
@@ -76,13 +79,14 @@ export type OidcRpGuardEvents<User> = {
76
79
  */
77
80
  export declare class OidcRpGuard<UserProvider extends SessionUserProviderContract<unknown>> implements GuardContract<RealUser<UserProvider>> {
78
81
  #private;
82
+ private persistent;
79
83
  [symbols.GUARD_KNOWN_EVENTS]: OidcRpGuardEvents<RealUser<UserProvider>>;
80
84
  driverName: 'oidc_rp';
81
85
  authenticationAttempted: boolean;
82
86
  isAuthenticated: boolean;
83
87
  isLoggedOut: boolean;
84
88
  user?: RealUser<UserProvider>;
85
- constructor(name: string, ctx: HttpContext, sessionKey: string, emitter: EmitterLike<OidcRpGuardEvents<RealUser<UserProvider>>>, userProvider: UserProvider, unauthorized?: UnauthorizedAccessConstructor);
89
+ constructor(name: string, ctx: HttpContext, sessionKey: string, emitter: EmitterLike<OidcRpGuardEvents<RealUser<UserProvider>>>, userProvider: UserProvider, unauthorized?: UnauthorizedAccessConstructor, persistent?: PersistentRpOptions);
86
90
  getUserOrFail(): RealUser<UserProvider>;
87
91
  /**
88
92
  * Grava a identidade na sessão. Chamado pelo callback OIDC após validar o
@@ -1,5 +1,7 @@
1
1
  import { RuntimeException } from '@adonisjs/core/exceptions';
2
2
  import { ACCOUNT_SESSION_KEY } from './account_session_key.js';
3
+ import { ACCOUNT_IDP_SESSION_KEY } from './idp_session_bridge.js';
4
+ import { endRpSession, ensureRpSession, } from './persistent_rp_session.js';
3
5
  /** Memo do construtor entre instâncias — o `import()` só paga o custo uma vez. */
4
6
  let cachedUnauthorizedAccess;
5
7
  /** O construtor já resolvido por {@link loadUnauthorizedAccess}, se houver. */
@@ -52,6 +54,7 @@ export async function loadUnauthorizedAccess(guardFactoryName = 'oidcRpGuard') {
52
54
  * tudo funciona nativamente.
53
55
  */
54
56
  export class OidcRpGuard {
57
+ persistent;
55
58
  #name;
56
59
  #ctx;
57
60
  #sessionKey;
@@ -63,7 +66,8 @@ export class OidcRpGuard {
63
66
  isAuthenticated = false;
64
67
  isLoggedOut = false;
65
68
  user;
66
- constructor(name, ctx, sessionKey, emitter, userProvider, unauthorized) {
69
+ constructor(name, ctx, sessionKey, emitter, userProvider, unauthorized, persistent = {}) {
70
+ this.persistent = persistent;
67
71
  this.#name = name;
68
72
  this.#ctx = ctx;
69
73
  this.#sessionKey = sessionKey;
@@ -96,6 +100,8 @@ export class OidcRpGuard {
96
100
  */
97
101
  async login(user) {
98
102
  const guardUser = await this.#userProvider.createUserForGuard(user);
103
+ if (this.persistent.acceptIdpSession)
104
+ this.#ctx.session.forget(ACCOUNT_IDP_SESSION_KEY);
99
105
  this.#ctx.session.put(this.#sessionKey, String(guardUser.getId()));
100
106
  this.user = user;
101
107
  this.isAuthenticated = true;
@@ -113,6 +119,8 @@ export class OidcRpGuard {
113
119
  */
114
120
  async logout() {
115
121
  const user = this.user ?? null;
122
+ if (this.persistent.acceptIdpSession)
123
+ await endRpSession(this.#ctx);
116
124
  this.#ctx.session.forget(this.#sessionKey);
117
125
  this.user = undefined;
118
126
  this.isAuthenticated = false;
@@ -129,6 +137,11 @@ export class OidcRpGuard {
129
137
  }
130
138
  this.authenticationAttempted = true;
131
139
  this.#unauthorized ??= await loadUnauthorizedAccess();
140
+ const acceptIdp = typeof this.persistent.acceptIdpSession === 'function'
141
+ ? this.persistent.acceptIdpSession(this.#ctx)
142
+ : this.persistent.acceptIdpSession;
143
+ if (acceptIdp)
144
+ await ensureRpSession(this.#ctx, this.#sessionKey);
132
145
  const userId = this.#ctx.session.get(this.#sessionKey);
133
146
  if (!userId) {
134
147
  this.#emitter.emit('oidc_rp:authentication_failed', {
@@ -197,7 +210,7 @@ export function oidcRpGuard(config) {
197
210
  userProvider = config.provider;
198
211
  }
199
212
  return (ctx) => {
200
- return new OidcRpGuard(name, ctx, sessionKey, emitter, userProvider, unauthorized);
213
+ return new OidcRpGuard(name, ctx, sessionKey, emitter, userProvider, unauthorized, { acceptIdpSession: config.acceptIdpSession });
201
214
  };
202
215
  },
203
216
  };
@@ -0,0 +1,8 @@
1
+ import type { HttpContext } from '@adonisjs/core/http';
2
+ export interface PersistentRpOptions {
3
+ acceptIdpSession?: boolean | ((ctx: HttpContext) => boolean);
4
+ }
5
+ /** Same-host RP restoration: the signed IdP cookie is the persistent credential. */
6
+ export declare function ensureRpSession(ctx: HttpContext, sessionKey: string): Promise<void>;
7
+ /** Logout must revoke the credential that could otherwise restore the RP. */
8
+ export declare function endRpSession(ctx: HttpContext): Promise<void>;
@@ -0,0 +1,74 @@
1
+ import { ACCOUNT_IDP_SESSION_KEY, readIdpSession } from './idp_session_bridge.js';
2
+ import { impersonationState } from './impersonation_session.js';
3
+ import { assertAccountEnabled } from './login_attempt.js';
4
+ /** Same-host RP restoration: the signed IdP cookie is the persistent credential. */
5
+ export async function ensureRpSession(ctx, sessionKey) {
6
+ const service = await ctx.containerResolver.make('authkit.server');
7
+ // Provider interactions write to the raw Node response. Regenerating Adonis's
8
+ // session there migrates its data after headers are sent, leaving the browser
9
+ // with the old cookie and losing the pending RP state/PKCE verifier.
10
+ const path = ctx.request.url?.().split('?', 1)[0] ?? '';
11
+ const mount = service.mountPath || service.config.mountPath;
12
+ if (path.startsWith('/auth/interaction/') ||
13
+ (mount && mount !== '/' && (path === mount || path.startsWith(`${mount}/`))) ||
14
+ ctx.route?.name === 'authkit.oidc.root' ||
15
+ ctx.route?.name === 'authkit.oidc.wildcard')
16
+ return;
17
+ const current = ctx.session.get(sessionKey);
18
+ const linkedUid = ctx.session.get(ACCOUNT_IDP_SESSION_KEY);
19
+ const idp = await readIdpSession(ctx, service, true);
20
+ const realId = current ? (impersonationState(ctx).impersonatorId ?? current) : undefined;
21
+ if (current && linkedUid && (!idp || idp.uid !== linkedUid || idp.accountId !== realId)) {
22
+ ctx.session.clear();
23
+ return;
24
+ }
25
+ // An existing local login cannot silently become a different IdP account.
26
+ if (current && (!idp || idp.accountId !== realId || idp.transient))
27
+ return;
28
+ if (!idp?.accountId || idp.transient)
29
+ return;
30
+ const account = await service.config.accountStore.findById(idp.accountId);
31
+ if (!account ||
32
+ !(await assertAccountEnabled(service.config, account.id, {
33
+ email: account.email ?? '',
34
+ ip: ctx.request.ip(),
35
+ })).allowed) {
36
+ if (current)
37
+ ctx.session.clear();
38
+ return;
39
+ }
40
+ const adapter = service.provider.Session.adapter;
41
+ const ttl = service.sessionTtlHolder.rememberSec;
42
+ if (adapter.renewSession) {
43
+ if (!(await adapter.renewSession(idp.id, ttl))) {
44
+ if (linkedUid)
45
+ ctx.session.clear();
46
+ return;
47
+ }
48
+ const kctx = service.provider.createContext(ctx.request.request, ctx.response.response);
49
+ kctx.cookies.set(service.provider.cookieName('session'), idp.id, {
50
+ signed: true,
51
+ httpOnly: true,
52
+ sameSite: 'lax',
53
+ path: '/',
54
+ secure: kctx.secure,
55
+ maxAge: ttl * 1000,
56
+ overwrite: true,
57
+ });
58
+ }
59
+ // Custom adapters without atomic renewal restore until the existing IdP expiry.
60
+ if (!current) {
61
+ await ctx.session.regenerate();
62
+ ctx.session.put(sessionKey, account.id);
63
+ }
64
+ ctx.session.put(ACCOUNT_IDP_SESSION_KEY, idp.uid);
65
+ }
66
+ /** Logout must revoke the credential that could otherwise restore the RP. */
67
+ export async function endRpSession(ctx) {
68
+ const service = await ctx.containerResolver.make('authkit.server');
69
+ const idp = await readIdpSession(ctx, service, true);
70
+ // The browser's signed IdP credential must be revoked even after its RP expired.
71
+ if (idp)
72
+ await idp.destroy();
73
+ ctx.session.forget(ACCOUNT_IDP_SESSION_KEY);
74
+ }