@adonis-agora/authkit-server 0.76.0 → 0.78.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 (88) hide show
  1. package/README.md +149 -0
  2. package/build/host/views/agents/consent.edge +13 -2
  3. package/build/host/views/agents/done.edge +3 -0
  4. package/build/host/views/mfa-challenge.edge +42 -3
  5. package/build/index.d.ts +19 -1
  6. package/build/index.js +13 -1
  7. package/build/providers/authkit_server_provider.js +15 -1
  8. package/build/src/accounts/account_store.d.ts +2 -0
  9. package/build/src/accounts/lucid_account_store.js +2 -0
  10. package/build/src/adapters/adapter_contract.d.ts +2 -0
  11. package/build/src/adapters/database_adapter.d.ts +1 -0
  12. package/build/src/adapters/database_adapter.js +26 -0
  13. package/build/src/adapters/redis_adapter.d.ts +1 -0
  14. package/build/src/adapters/redis_adapter.js +30 -0
  15. package/build/src/agents/config.d.ts +20 -3
  16. package/build/src/agents/config.js +19 -4
  17. package/build/src/agents/delegation_service.js +18 -8
  18. package/build/src/agents/middleware.js +2 -1
  19. package/build/src/agents/poppy/client_registry.d.ts +45 -0
  20. package/build/src/agents/poppy/client_registry.js +226 -0
  21. package/build/src/agents/poppy/config.d.ts +362 -0
  22. package/build/src/agents/poppy/config.js +201 -0
  23. package/build/src/agents/poppy/dpop.d.ts +61 -0
  24. package/build/src/agents/poppy/dpop.js +158 -0
  25. package/build/src/agents/poppy/errors.d.ts +20 -0
  26. package/build/src/agents/poppy/errors.js +22 -0
  27. package/build/src/agents/poppy/replay.d.ts +25 -0
  28. package/build/src/agents/poppy/replay.js +106 -0
  29. package/build/src/agents/poppy/resource.d.ts +95 -0
  30. package/build/src/agents/poppy/resource.js +125 -0
  31. package/build/src/agents/poppy/safe_fetch.d.ts +48 -0
  32. package/build/src/agents/poppy/safe_fetch.js +256 -0
  33. package/build/src/agents/poppy/service.d.ts +280 -0
  34. package/build/src/agents/poppy/service.js +1452 -0
  35. package/build/src/agents/poppy/store.d.ts +109 -0
  36. package/build/src/agents/poppy/store.js +260 -0
  37. package/build/src/agents/poppy/web_session.d.ts +32 -0
  38. package/build/src/agents/poppy/web_session.js +104 -0
  39. package/build/src/agents/protocol.d.ts +8 -4
  40. package/build/src/agents/runtime.d.ts +17 -1
  41. package/build/src/agents/runtime.js +138 -13
  42. package/build/src/define_config.d.ts +24 -0
  43. package/build/src/define_config.js +14 -0
  44. package/build/src/host/account_api/account_api_controller.d.ts +12 -1
  45. package/build/src/host/account_api/account_api_controller.js +10 -5
  46. package/build/src/host/admin_sessions_service.js +8 -5
  47. package/build/src/host/auth_host_config.d.ts +2 -1
  48. package/build/src/host/controllers/account_apps_controller.js +13 -3
  49. package/build/src/host/controllers/account_security_controller.js +8 -4
  50. package/build/src/host/controllers/interaction_controller.d.ts +13 -1
  51. package/build/src/host/controllers/interaction_controller.js +352 -24
  52. package/build/src/host/controllers/poppy_consent_controller.d.ts +19 -0
  53. package/build/src/host/controllers/poppy_consent_controller.js +173 -0
  54. package/build/src/host/controllers/poppy_controller.d.ts +25 -0
  55. package/build/src/host/controllers/poppy_controller.js +194 -0
  56. package/build/src/host/csrf.d.ts +7 -0
  57. package/build/src/host/csrf.js +16 -1
  58. package/build/src/host/custom_login.d.ts +36 -0
  59. package/build/src/host/custom_login.js +76 -0
  60. package/build/src/host/custom_login_completion.d.ts +4 -0
  61. package/build/src/host/custom_login_completion.js +33 -0
  62. package/build/src/host/custom_mfa.d.ts +77 -0
  63. package/build/src/host/custom_mfa.js +268 -0
  64. package/build/src/host/i18n.d.ts +8 -0
  65. package/build/src/host/i18n.js +14 -6
  66. package/build/src/host/idp_session_bridge.d.ts +5 -3
  67. package/build/src/host/idp_session_bridge.js +5 -3
  68. package/build/src/host/oidc_rp_guard.d.ts +5 -1
  69. package/build/src/host/oidc_rp_guard.js +15 -2
  70. package/build/src/host/persistent_rp_session.d.ts +8 -0
  71. package/build/src/host/persistent_rp_session.js +74 -0
  72. package/build/src/host/poppy_session_key.d.ts +9 -0
  73. package/build/src/host/poppy_session_key.js +9 -0
  74. package/build/src/host/register_auth_host.js +72 -0
  75. package/build/src/host/whatsapp_code_sender.d.ts +20 -0
  76. package/build/src/host/whatsapp_code_sender.js +9 -0
  77. package/build/src/host/whatsapp_senders/http.d.ts +5 -0
  78. package/build/src/host/whatsapp_senders/http.js +33 -0
  79. package/build/src/host/whatsapp_senders/meta_whatsapp_code_sender.d.ts +20 -0
  80. package/build/src/host/whatsapp_senders/meta_whatsapp_code_sender.js +51 -0
  81. package/build/src/host/whatsapp_senders/whatsmiau_code_sender.d.ts +14 -0
  82. package/build/src/host/whatsapp_senders/whatsmiau_code_sender.js +36 -0
  83. package/build/src/provider/oidc_service.d.ts +5 -0
  84. package/build/src/provider/oidc_service.js +15 -4
  85. package/build/src/schema/ensure.js +124 -0
  86. package/build/stubs/ui/react/pages/mfa-challenge.tsx +207 -37
  87. package/package.json +2 -1
  88. package/stubs/ui/react/pages/mfa-challenge.tsx +207 -37
@@ -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;
@@ -202,6 +204,8 @@ export declare const DEFAULT_MESSAGES: {
202
204
  'agents.done.revoke_hint': string;
203
205
  'agents.done.expired_title': string;
204
206
  'agents.done.expired_body': string;
207
+ 'agents.done.invalid_title': string;
208
+ 'agents.done.invalid_body': string;
205
209
  'account.apps.not_supported': string;
206
210
  'account.orgs.page_title': string;
207
211
  'account.orgs.title': string;
@@ -836,6 +840,8 @@ export declare const PT_BR_MESSAGES: {
836
840
  'mfa_challenge.page_title': string;
837
841
  'mfa_challenge.title': string;
838
842
  'mfa_challenge.intro': string;
843
+ 'mfa_challenge.start': string;
844
+ 'mfa_challenge.progress': string;
839
845
  'mfa_challenge.code_label': string;
840
846
  'mfa_challenge.submit': string;
841
847
  'mfa_challenge.recovery_summary': string;
@@ -952,6 +958,8 @@ export declare const PT_BR_MESSAGES: {
952
958
  'agents.done.revoke_hint': string;
953
959
  'agents.done.expired_title': string;
954
960
  'agents.done.expired_body': string;
961
+ 'agents.done.invalid_title': string;
962
+ 'agents.done.invalid_body': string;
955
963
  'account.apps.not_supported': string;
956
964
  'account.orgs.page_title': string;
957
965
  'account.orgs.title': 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',
@@ -210,6 +212,8 @@ export const DEFAULT_MESSAGES = {
210
212
  'agents.done.revoke_hint': 'To revoke this access later, go to the apps page of your account.',
211
213
  'agents.done.expired_title': 'Request expired',
212
214
  'agents.done.expired_body': 'This request is no longer valid. Ask your assistant to start again.',
215
+ 'agents.done.invalid_title': 'Invalid request',
216
+ 'agents.done.invalid_body': 'This sign-in link is not valid. Go back to your assistant and start again.',
213
217
  'account.apps.not_supported': 'The configured OIDC adapter does not support enumeration — listing apps is unavailable.',
214
218
  // Console de conta — organizations (account/orgs).
215
219
  'account.orgs.page_title': 'My Organizations',
@@ -920,9 +924,11 @@ export const PT_BR_MESSAGES = {
920
924
  'verify_email.invalid_title': 'Link inválido',
921
925
  'verify_email.invalid_body': 'O link de verificação é inválido ou já foi utilizado.',
922
926
  // 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.',
927
+ 'mfa_challenge.page_title': 'Verificação multifator',
928
+ 'mfa_challenge.title': 'Verificação multifator',
929
+ 'mfa_challenge.intro': 'Verifique sua identidade usando um método disponível.',
930
+ 'mfa_challenge.start': 'Iniciar verificação',
931
+ 'mfa_challenge.progress': '{completed} de {required} fatores verificados.',
926
932
  'mfa_challenge.code_label': 'Código',
927
933
  'mfa_challenge.submit': 'Verificar',
928
934
  'mfa_challenge.recovery_summary': 'Usar um código de recuperação',
@@ -1048,6 +1054,8 @@ export const PT_BR_MESSAGES = {
1048
1054
  'agents.done.revoke_hint': 'Para revogar este acesso depois, vá à página de apps da sua conta.',
1049
1055
  'agents.done.expired_title': 'Pedido expirado',
1050
1056
  'agents.done.expired_body': 'Este pedido não vale mais. Peça ao seu assistente para começar de novo.',
1057
+ 'agents.done.invalid_title': 'Pedido inválido',
1058
+ 'agents.done.invalid_body': 'Este link de acesso não é válido. Volte ao seu assistente e comece de novo.',
1051
1059
  'account.apps.not_supported': 'O adapter OIDC configurado não suporta enumeração — a listagem de apps fica indisponível.',
1052
1060
  // Console de conta — organizations (account/orgs).
1053
1061
  'account.orgs.page_title': 'Minhas Organizações',
@@ -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
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Session key que marca a sessão do navegador de um personal agent Poppy (§5):
3
+ * `{ sessionId, clientId, userId }`. Import-free (o barrel a reexporta — ver
4
+ * `account_session_key.ts`).
5
+ *
6
+ * Uma sessão marcada é do AGENTE, não do usuário: o console de conta, o admin e
7
+ * as telas de consentimento a recusam.
8
+ */
9
+ export declare const POPPY_WEB_SESSION_KEY = "authkit_poppy_session";
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Session key que marca a sessão do navegador de um personal agent Poppy (§5):
3
+ * `{ sessionId, clientId, userId }`. Import-free (o barrel a reexporta — ver
4
+ * `account_session_key.ts`).
5
+ *
6
+ * Uma sessão marcada é do AGENTE, não do usuário: o console de conta, o admin e
7
+ * as telas de consentimento a recusam.
8
+ */
9
+ export const POPPY_WEB_SESSION_KEY = 'authkit_poppy_session';