@adonis-agora/authkit-server 0.75.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 +148 -0
- package/build/host/views/account/apps.edge +30 -0
- package/build/host/views/agents/consent.edge +60 -0
- package/build/host/views/agents/done.edge +24 -0
- package/build/host/views/consent.edge +9 -1
- package/build/host/views/mfa-challenge.edge +42 -3
- package/build/host/views/partials/styles.edge +1 -1
- package/build/index.d.ts +18 -1
- package/build/index.js +9 -1
- package/build/providers/authkit_server_provider.js +75 -50
- 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/agents/agent_identity.d.ts +33 -0
- package/build/src/agents/agent_identity.js +156 -0
- package/build/src/agents/config.d.ts +99 -0
- package/build/src/agents/config.js +101 -0
- package/build/src/agents/delegation_service.d.ts +154 -0
- package/build/src/agents/delegation_service.js +394 -0
- package/build/src/agents/delegation_store.d.ts +93 -0
- package/build/src/agents/delegation_store.js +222 -0
- package/build/src/agents/middleware.d.ts +73 -0
- package/build/src/agents/middleware.js +113 -0
- package/build/src/agents/protocol.d.ts +62 -0
- package/build/src/agents/protocol.js +51 -0
- package/build/src/agents/runtime.d.ts +36 -0
- package/build/src/agents/runtime.js +65 -0
- package/build/src/agents/signer.d.ts +34 -0
- package/build/src/agents/signer.js +70 -0
- package/build/src/audit/audit_sink.d.ts +1 -1
- package/build/src/audit/audit_sink.js +5 -0
- package/build/src/controllers/authorization_server_metadata_controller.d.ts +10 -0
- package/build/src/controllers/authorization_server_metadata_controller.js +20 -0
- package/build/src/define_config.d.ts +50 -3
- package/build/src/define_config.js +42 -1
- package/build/src/host/account_api/account_api_controller.d.ts +12 -0
- package/build/src/host/account_api/account_api_controller.js +40 -0
- package/build/src/host/admin_api/dto.d.ts +1 -1
- package/build/src/host/admin_sessions_service.d.ts +2 -0
- package/build/src/host/admin_sessions_service.js +18 -0
- package/build/src/host/auth_host_config.d.ts +4 -0
- package/build/src/host/client_names.d.ts +11 -0
- package/build/src/host/client_names.js +12 -0
- package/build/src/host/controllers/account_apps_controller.d.ts +2 -0
- package/build/src/host/controllers/account_apps_controller.js +39 -3
- package/build/src/host/controllers/account_orgs_controller.js +2 -1
- package/build/src/host/controllers/account_security_controller.js +8 -4
- package/build/src/host/controllers/account_session_controller.js +2 -1
- package/build/src/host/controllers/agent_consent_controller.d.ts +17 -0
- package/build/src/host/controllers/agent_consent_controller.js +138 -0
- package/build/src/host/controllers/agent_oauth_controller.d.ts +23 -0
- package/build/src/host/controllers/agent_oauth_controller.js +110 -0
- package/build/src/host/controllers/interaction_controller.d.ts +13 -1
- package/build/src/host/controllers/interaction_controller.js +380 -24
- package/build/src/host/csrf.d.ts +8 -18
- package/build/src/host/csrf.js +28 -2
- 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 +58 -0
- package/build/src/host/i18n.js +64 -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/redirect_exact.d.ts +12 -0
- package/build/src/host/redirect_exact.js +17 -0
- package/build/src/host/register_auth_host.js +60 -7
- package/build/src/host/request_url.d.ts +10 -0
- package/build/src/host/request_url.js +17 -0
- package/build/src/host/sudo/methods/magic_link.js +2 -1
- package/build/src/host/sudo/runtime.js +3 -2
- package/build/src/host/sudo_mode.js +4 -4
- 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/mcp/mcp_oauth.d.ts +85 -0
- package/build/src/mcp/mcp_oauth.js +154 -0
- package/build/src/provider/build_provider.js +15 -1
- package/build/src/provider/oidc_service.d.ts +8 -0
- package/build/src/provider/oidc_service.js +16 -2
- package/build/src/schema/ensure.js +92 -0
- package/build/stubs/ui/react/pages/consent.tsx +17 -1
- package/build/stubs/ui/react/pages/mfa-challenge.tsx +207 -37
- package/package.json +2 -1
- package/stubs/ui/react/pages/consent.tsx +17 -1
- package/stubs/ui/react/pages/mfa-challenge.tsx +207 -37
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { HttpContext } from '@adonisjs/core/http';
|
|
2
|
+
/** A verified identity, obtained from trusted host logic rather than request.accountId. */
|
|
3
|
+
export interface CustomLoginIdentity {
|
|
4
|
+
accountId: string;
|
|
5
|
+
/** Host-validated remember-me choice, subject to the runtime session policy. */
|
|
6
|
+
remember?: boolean;
|
|
7
|
+
}
|
|
8
|
+
/** Host-defined primary authentication. AuthKit owns policies, MFA and session completion. */
|
|
9
|
+
export interface CustomLoginMethod {
|
|
10
|
+
/** Credential group, used to prevent repeating it as an additional MFA factor. */
|
|
11
|
+
readonly factorId?: string;
|
|
12
|
+
/** Set true only for proofs that do not authenticate with the account password. */
|
|
13
|
+
readonly passwordless?: boolean;
|
|
14
|
+
/** Optional challenge initiation; response shape, validation and delivery are host-owned. */
|
|
15
|
+
begin?(ctx: HttpContext): Promise<unknown>;
|
|
16
|
+
/** Validate/consume the primary proof, bound to this interaction. Null denies login. */
|
|
17
|
+
authenticate(ctx: HttpContext): Promise<CustomLoginIdentity | null>;
|
|
18
|
+
}
|
|
19
|
+
/** Constructors are resolved through the request container, including @inject dependencies. */
|
|
20
|
+
export type CustomLoginMethodConstructor = new (...args: never[]) => CustomLoginMethod;
|
|
21
|
+
export type CustomLoginMethodBinding = CustomLoginMethodConstructor | CustomLoginMethod;
|
|
22
|
+
export type CustomLoginMethods = Readonly<Record<string, CustomLoginMethodBinding>>;
|
|
23
|
+
export declare function isCustomLoginMethodId(method: string): boolean;
|
|
24
|
+
/** Start a registered method's optional challenge within a valid OIDC login interaction. */
|
|
25
|
+
export declare function beginCustomLogin(ctx: HttpContext, method: string): Promise<unknown>;
|
|
26
|
+
/** Verify using a registered class/instance, then pass through the shared login gates. */
|
|
27
|
+
export declare function authenticateCustomLogin(ctx: HttpContext, method: string): Promise<unknown>;
|
|
28
|
+
/**
|
|
29
|
+
* Trusted host escape hatch for arbitrary flows. Call ONLY after validating a primary proof.
|
|
30
|
+
* Never forward an account ID supplied by the browser. Prefer a registered CustomLoginMethod.
|
|
31
|
+
*/
|
|
32
|
+
export declare function completeCustomLogin(ctx: HttpContext, input: CustomLoginIdentity & {
|
|
33
|
+
method: string;
|
|
34
|
+
factorId?: string;
|
|
35
|
+
passwordless?: boolean;
|
|
36
|
+
}): Promise<unknown>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Exception } from '@adonisjs/core/exceptions';
|
|
2
|
+
import AuthInteractionController from './controllers/interaction_controller.js';
|
|
3
|
+
export function isCustomLoginMethodId(method) {
|
|
4
|
+
return (/^[a-z][a-z0-9:_-]{0,63}$/.test(method) &&
|
|
5
|
+
!['pwd', 'email', 'mfa', 'totp', 'webauthn', 'recovery'].includes(method));
|
|
6
|
+
}
|
|
7
|
+
function failure(message, code, status = 422) {
|
|
8
|
+
return new Exception(message, { code, status });
|
|
9
|
+
}
|
|
10
|
+
async function resolveMethod(ctx, method) {
|
|
11
|
+
if (!isCustomLoginMethodId(method))
|
|
12
|
+
throw failure('Invalid custom login method', 'E_CUSTOM_LOGIN_METHOD');
|
|
13
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
14
|
+
const details = await service.interactions.details(ctx);
|
|
15
|
+
if (details.uid !== ctx.request.param('uid') || details.prompt.name !== 'login') {
|
|
16
|
+
throw failure('Invalid login interaction', 'E_CUSTOM_LOGIN_INTERACTION');
|
|
17
|
+
}
|
|
18
|
+
const registry = service.config.customLoginMethods;
|
|
19
|
+
if (!registry || !Object.hasOwn(registry, method)) {
|
|
20
|
+
throw failure('Unknown custom login method', 'E_CUSTOM_LOGIN_METHOD');
|
|
21
|
+
}
|
|
22
|
+
const binding = registry[method];
|
|
23
|
+
return typeof binding === 'function' ? ctx.containerResolver.make(binding) : binding;
|
|
24
|
+
}
|
|
25
|
+
/** Start a registered method's optional challenge within a valid OIDC login interaction. */
|
|
26
|
+
export async function beginCustomLogin(ctx, method) {
|
|
27
|
+
const handler = await resolveMethod(ctx, method);
|
|
28
|
+
if (!handler.begin)
|
|
29
|
+
throw failure('This method has no challenge initiation', 'E_CUSTOM_LOGIN_BEGIN');
|
|
30
|
+
return handler.begin(ctx);
|
|
31
|
+
}
|
|
32
|
+
/** Verify using a registered class/instance, then pass through the shared login gates. */
|
|
33
|
+
export async function authenticateCustomLogin(ctx, method) {
|
|
34
|
+
const handler = await resolveMethod(ctx, method);
|
|
35
|
+
let identity;
|
|
36
|
+
try {
|
|
37
|
+
identity = await handler.authenticate(ctx);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
await recordFailure(ctx, method, 'method_error');
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
if (!identity || typeof identity.accountId !== 'string' || !identity.accountId.trim()) {
|
|
44
|
+
await recordFailure(ctx, method, 'invalid_proof');
|
|
45
|
+
throw failure('Authentication failed', 'E_CUSTOM_LOGIN_FAILED');
|
|
46
|
+
}
|
|
47
|
+
return completeCustomLogin(ctx, {
|
|
48
|
+
...identity,
|
|
49
|
+
method,
|
|
50
|
+
factorId: handler.factorId,
|
|
51
|
+
passwordless: handler.passwordless === true,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Trusted host escape hatch for arbitrary flows. Call ONLY after validating a primary proof.
|
|
56
|
+
* Never forward an account ID supplied by the browser. Prefer a registered CustomLoginMethod.
|
|
57
|
+
*/
|
|
58
|
+
export function completeCustomLogin(ctx, input) {
|
|
59
|
+
return new AuthInteractionController().completeCustomLogin(ctx, input);
|
|
60
|
+
}
|
|
61
|
+
async function recordFailure(ctx, method, reason) {
|
|
62
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
63
|
+
const details = await service.interactions.details(ctx);
|
|
64
|
+
try {
|
|
65
|
+
await service.config.audit?.record({
|
|
66
|
+
type: 'login.failure',
|
|
67
|
+
accountId: null,
|
|
68
|
+
ip: ctx.request.ip(),
|
|
69
|
+
clientId: details.params.client_id ?? null,
|
|
70
|
+
metadata: { method, reason },
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
ctx.logger.warn('AuthKit custom login failure audit could not be recorded');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { HttpContext } from '@adonisjs/core/http';
|
|
2
|
+
import type { CompleteLoginExtra } from '../provider/interaction_actions.js';
|
|
3
|
+
/** Shared by direct custom completion and its TOTP/passkey continuation. */
|
|
4
|
+
export declare function completeCustomLoginSession(ctx: HttpContext, accountId: string, extra: CompleteLoginExtra, rememberRequested?: boolean): Promise<unknown>;
|
|
@@ -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;
|
|
@@ -97,6 +99,11 @@ export declare const DEFAULT_MESSAGES: {
|
|
|
97
99
|
'consent.title': string;
|
|
98
100
|
'consent.body': string;
|
|
99
101
|
'consent.submit': string;
|
|
102
|
+
'consent.scopes_title': string;
|
|
103
|
+
'consent.scope.profile': string;
|
|
104
|
+
'consent.scope.email': string;
|
|
105
|
+
'consent.scope.offline_access': string;
|
|
106
|
+
'consent.scope.roles': string;
|
|
100
107
|
'account.login.page_title': string;
|
|
101
108
|
'account.login.title': string;
|
|
102
109
|
'account.login.intro': string;
|
|
@@ -175,6 +182,28 @@ export declare const DEFAULT_MESSAGES: {
|
|
|
175
182
|
'account.apps.revoke': string;
|
|
176
183
|
'account.apps.revoke_confirm': string;
|
|
177
184
|
'account.apps.revoked': string;
|
|
185
|
+
'account.apps.agents_title': string;
|
|
186
|
+
'account.apps.agents_intro': string;
|
|
187
|
+
'account.apps.agents_empty': string;
|
|
188
|
+
'agents.consent.page_title': string;
|
|
189
|
+
'agents.consent.title': string;
|
|
190
|
+
'agents.consent.body': string;
|
|
191
|
+
'agents.consent.signed_in_as': string;
|
|
192
|
+
'agents.consent.scopes_label': string;
|
|
193
|
+
'agents.consent.code': string;
|
|
194
|
+
'agents.consent.allow': string;
|
|
195
|
+
'agents.consent.deny': string;
|
|
196
|
+
'agents.consent.enter_code_title': string;
|
|
197
|
+
'agents.consent.enter_code_body': string;
|
|
198
|
+
'agents.consent.continue': string;
|
|
199
|
+
'agents.consent.invalid_code': string;
|
|
200
|
+
'agents.consent.impersonating': string;
|
|
201
|
+
'agents.done.approved_title': string;
|
|
202
|
+
'agents.done.denied_title': string;
|
|
203
|
+
'agents.done.return': string;
|
|
204
|
+
'agents.done.revoke_hint': string;
|
|
205
|
+
'agents.done.expired_title': string;
|
|
206
|
+
'agents.done.expired_body': string;
|
|
178
207
|
'account.apps.not_supported': string;
|
|
179
208
|
'account.orgs.page_title': string;
|
|
180
209
|
'account.orgs.title': string;
|
|
@@ -809,6 +838,8 @@ export declare const PT_BR_MESSAGES: {
|
|
|
809
838
|
'mfa_challenge.page_title': string;
|
|
810
839
|
'mfa_challenge.title': string;
|
|
811
840
|
'mfa_challenge.intro': string;
|
|
841
|
+
'mfa_challenge.start': string;
|
|
842
|
+
'mfa_challenge.progress': string;
|
|
812
843
|
'mfa_challenge.code_label': string;
|
|
813
844
|
'mfa_challenge.submit': string;
|
|
814
845
|
'mfa_challenge.recovery_summary': string;
|
|
@@ -820,6 +851,11 @@ export declare const PT_BR_MESSAGES: {
|
|
|
820
851
|
'consent.title': string;
|
|
821
852
|
'consent.body': string;
|
|
822
853
|
'consent.submit': string;
|
|
854
|
+
'consent.scopes_title': string;
|
|
855
|
+
'consent.scope.profile': string;
|
|
856
|
+
'consent.scope.email': string;
|
|
857
|
+
'consent.scope.offline_access': string;
|
|
858
|
+
'consent.scope.roles': string;
|
|
823
859
|
'account.login.page_title': string;
|
|
824
860
|
'account.login.title': string;
|
|
825
861
|
'account.login.intro': string;
|
|
@@ -898,6 +934,28 @@ export declare const PT_BR_MESSAGES: {
|
|
|
898
934
|
'account.apps.revoke': string;
|
|
899
935
|
'account.apps.revoke_confirm': string;
|
|
900
936
|
'account.apps.revoked': string;
|
|
937
|
+
'account.apps.agents_title': string;
|
|
938
|
+
'account.apps.agents_intro': string;
|
|
939
|
+
'account.apps.agents_empty': string;
|
|
940
|
+
'agents.consent.page_title': string;
|
|
941
|
+
'agents.consent.title': string;
|
|
942
|
+
'agents.consent.body': string;
|
|
943
|
+
'agents.consent.signed_in_as': string;
|
|
944
|
+
'agents.consent.scopes_label': string;
|
|
945
|
+
'agents.consent.code': string;
|
|
946
|
+
'agents.consent.allow': string;
|
|
947
|
+
'agents.consent.deny': string;
|
|
948
|
+
'agents.consent.enter_code_title': string;
|
|
949
|
+
'agents.consent.enter_code_body': string;
|
|
950
|
+
'agents.consent.continue': string;
|
|
951
|
+
'agents.consent.invalid_code': string;
|
|
952
|
+
'agents.consent.impersonating': string;
|
|
953
|
+
'agents.done.approved_title': string;
|
|
954
|
+
'agents.done.denied_title': string;
|
|
955
|
+
'agents.done.return': string;
|
|
956
|
+
'agents.done.revoke_hint': string;
|
|
957
|
+
'agents.done.expired_title': string;
|
|
958
|
+
'agents.done.expired_body': string;
|
|
901
959
|
'account.apps.not_supported': string;
|
|
902
960
|
'account.orgs.page_title': string;
|
|
903
961
|
'account.orgs.title': string;
|