@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.
- package/README.md +145 -0
- package/build/host/views/mfa-challenge.edge +42 -3
- package/build/index.d.ts +9 -1
- package/build/index.js +5 -1
- package/build/src/accounts/account_store.d.ts +2 -0
- package/build/src/accounts/lucid_account_store.js +2 -0
- package/build/src/adapters/adapter_contract.d.ts +2 -0
- package/build/src/adapters/database_adapter.d.ts +1 -0
- package/build/src/adapters/database_adapter.js +26 -0
- package/build/src/adapters/redis_adapter.d.ts +1 -0
- package/build/src/adapters/redis_adapter.js +30 -0
- package/build/src/define_config.d.ts +24 -0
- package/build/src/define_config.js +8 -0
- package/build/src/host/controllers/account_security_controller.js +8 -4
- package/build/src/host/controllers/interaction_controller.d.ts +13 -1
- package/build/src/host/controllers/interaction_controller.js +352 -24
- package/build/src/host/custom_login.d.ts +36 -0
- package/build/src/host/custom_login.js +76 -0
- package/build/src/host/custom_login_completion.d.ts +4 -0
- package/build/src/host/custom_login_completion.js +33 -0
- package/build/src/host/custom_mfa.d.ts +77 -0
- package/build/src/host/custom_mfa.js +268 -0
- package/build/src/host/i18n.d.ts +4 -0
- package/build/src/host/i18n.js +10 -6
- package/build/src/host/idp_session_bridge.d.ts +5 -3
- package/build/src/host/idp_session_bridge.js +5 -3
- package/build/src/host/oidc_rp_guard.d.ts +5 -1
- package/build/src/host/oidc_rp_guard.js +15 -2
- package/build/src/host/persistent_rp_session.d.ts +8 -0
- package/build/src/host/persistent_rp_session.js +74 -0
- package/build/src/host/register_auth_host.js +12 -0
- package/build/src/host/whatsapp_code_sender.d.ts +20 -0
- package/build/src/host/whatsapp_code_sender.js +9 -0
- package/build/src/host/whatsapp_senders/http.d.ts +5 -0
- package/build/src/host/whatsapp_senders/http.js +33 -0
- package/build/src/host/whatsapp_senders/meta_whatsapp_code_sender.d.ts +20 -0
- package/build/src/host/whatsapp_senders/meta_whatsapp_code_sender.js +51 -0
- package/build/src/host/whatsapp_senders/whatsmiau_code_sender.d.ts +14 -0
- package/build/src/host/whatsapp_senders/whatsmiau_code_sender.js +36 -0
- package/build/src/provider/oidc_service.js +6 -2
- package/build/stubs/ui/react/pages/mfa-challenge.tsx +207 -37
- package/package.json +2 -1
- 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
|
+
}
|
package/build/src/host/i18n.d.ts
CHANGED
|
@@ -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;
|
package/build/src/host/i18n.js
CHANGED
|
@@ -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': '
|
|
84
|
-
'mfa_challenge.title': '
|
|
85
|
-
'mfa_challenge.intro': '
|
|
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
|
|
924
|
-
'mfa_challenge.title': 'Verificação
|
|
925
|
-
'mfa_challenge.intro': '
|
|
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
|
+
}
|