@endora-commerce/mod-mfa 0.100.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/LICENSE +21 -0
- package/README.md +58 -0
- package/dist/admin/index.d.ts +37 -0
- package/dist/admin/index.d.ts.map +1 -0
- package/dist/admin/index.js +12 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/admin/pages/AdminSecuritySettings.d.ts +3 -0
- package/dist/admin/pages/AdminSecuritySettings.d.ts.map +1 -0
- package/dist/admin/pages/AdminSecuritySettings.js +71 -0
- package/dist/admin/pages/AdminSecuritySettings.js.map +1 -0
- package/dist/backend/entities/mfa-enrolment.entity.d.ts +33 -0
- package/dist/backend/entities/mfa-enrolment.entity.d.ts.map +1 -0
- package/dist/backend/entities/mfa-enrolment.entity.js +95 -0
- package/dist/backend/entities/mfa-enrolment.entity.js.map +1 -0
- package/dist/backend/entities/mfa-organization-policy.entity.d.ts +19 -0
- package/dist/backend/entities/mfa-organization-policy.entity.d.ts.map +1 -0
- package/dist/backend/entities/mfa-organization-policy.entity.js +60 -0
- package/dist/backend/entities/mfa-organization-policy.entity.js.map +1 -0
- package/dist/backend/entities/mfa-recovery-code.entity.d.ts +17 -0
- package/dist/backend/entities/mfa-recovery-code.entity.d.ts.map +1 -0
- package/dist/backend/entities/mfa-recovery-code.entity.js +54 -0
- package/dist/backend/entities/mfa-recovery-code.entity.js.map +1 -0
- package/dist/backend/entities/mfa-social-identity.entity.d.ts +24 -0
- package/dist/backend/entities/mfa-social-identity.entity.d.ts.map +1 -0
- package/dist/backend/entities/mfa-social-identity.entity.js +73 -0
- package/dist/backend/entities/mfa-social-identity.entity.js.map +1 -0
- package/dist/backend/index.d.ts +131 -0
- package/dist/backend/index.d.ts.map +1 -0
- package/dist/backend/index.js +173 -0
- package/dist/backend/index.js.map +1 -0
- package/dist/backend/plugin.d.ts +99 -0
- package/dist/backend/plugin.d.ts.map +1 -0
- package/dist/backend/plugin.js +111 -0
- package/dist/backend/plugin.js.map +1 -0
- package/dist/backend/routes.admin.d.ts +23 -0
- package/dist/backend/routes.admin.d.ts.map +1 -0
- package/dist/backend/routes.admin.js +93 -0
- package/dist/backend/routes.admin.js.map +1 -0
- package/dist/backend/routes.oauth.d.ts +26 -0
- package/dist/backend/routes.oauth.d.ts.map +1 -0
- package/dist/backend/routes.oauth.js +155 -0
- package/dist/backend/routes.oauth.js.map +1 -0
- package/dist/backend/routes.org.d.ts +21 -0
- package/dist/backend/routes.org.d.ts.map +1 -0
- package/dist/backend/routes.org.js +17 -0
- package/dist/backend/routes.org.js.map +1 -0
- package/dist/backend/routes.public.d.ts +21 -0
- package/dist/backend/routes.public.d.ts.map +1 -0
- package/dist/backend/routes.public.js +118 -0
- package/dist/backend/routes.public.js.map +1 -0
- package/dist/backend/routes.self-service.d.ts +34 -0
- package/dist/backend/routes.self-service.d.ts.map +1 -0
- package/dist/backend/routes.self-service.js +113 -0
- package/dist/backend/routes.self-service.js.map +1 -0
- package/dist/backend/services/account-identity.d.ts +71 -0
- package/dist/backend/services/account-identity.d.ts.map +1 -0
- package/dist/backend/services/account-identity.js +87 -0
- package/dist/backend/services/account-identity.js.map +1 -0
- package/dist/backend/services/challenge-store.d.ts +40 -0
- package/dist/backend/services/challenge-store.d.ts.map +1 -0
- package/dist/backend/services/challenge-store.js +90 -0
- package/dist/backend/services/challenge-store.js.map +1 -0
- package/dist/backend/services/mfa-enrolment-count.service.d.ts +26 -0
- package/dist/backend/services/mfa-enrolment-count.service.d.ts.map +1 -0
- package/dist/backend/services/mfa-enrolment-count.service.js +34 -0
- package/dist/backend/services/mfa-enrolment-count.service.js.map +1 -0
- package/dist/backend/services/mfa-enrolment-service.d.ts +54 -0
- package/dist/backend/services/mfa-enrolment-service.d.ts.map +1 -0
- package/dist/backend/services/mfa-enrolment-service.js +217 -0
- package/dist/backend/services/mfa-enrolment-service.js.map +1 -0
- package/dist/backend/services/mfa-enrolment-state.service.d.ts +27 -0
- package/dist/backend/services/mfa-enrolment-state.service.d.ts.map +1 -0
- package/dist/backend/services/mfa-enrolment-state.service.js +33 -0
- package/dist/backend/services/mfa-enrolment-state.service.js.map +1 -0
- package/dist/backend/services/mfa-login-service.d.ts +56 -0
- package/dist/backend/services/mfa-login-service.d.ts.map +1 -0
- package/dist/backend/services/mfa-login-service.js +89 -0
- package/dist/backend/services/mfa-login-service.js.map +1 -0
- package/dist/backend/services/mfa-org-policy-service.d.ts +16 -0
- package/dist/backend/services/mfa-org-policy-service.d.ts.map +1 -0
- package/dist/backend/services/mfa-org-policy-service.js +43 -0
- package/dist/backend/services/mfa-org-policy-service.js.map +1 -0
- package/dist/backend/services/mfa-policy-resolver.d.ts +47 -0
- package/dist/backend/services/mfa-policy-resolver.d.ts.map +1 -0
- package/dist/backend/services/mfa-policy-resolver.js +82 -0
- package/dist/backend/services/mfa-policy-resolver.js.map +1 -0
- package/dist/backend/services/oauth-provider-service.d.ts +65 -0
- package/dist/backend/services/oauth-provider-service.d.ts.map +1 -0
- package/dist/backend/services/oauth-provider-service.js +80 -0
- package/dist/backend/services/oauth-provider-service.js.map +1 -0
- package/dist/backend/services/recovery-codes.d.ts +10 -0
- package/dist/backend/services/recovery-codes.d.ts.map +1 -0
- package/dist/backend/services/recovery-codes.js +18 -0
- package/dist/backend/services/recovery-codes.js.map +1 -0
- package/dist/backend/services/secret-cipher.d.ts +16 -0
- package/dist/backend/services/secret-cipher.d.ts.map +1 -0
- package/dist/backend/services/secret-cipher.js +52 -0
- package/dist/backend/services/secret-cipher.js.map +1 -0
- package/dist/backend/services/social-identity-service.d.ts +40 -0
- package/dist/backend/services/social-identity-service.d.ts.map +1 -0
- package/dist/backend/services/social-identity-service.js +69 -0
- package/dist/backend/services/social-identity-service.js.map +1 -0
- package/dist/backend/services/social-link-service.d.ts +63 -0
- package/dist/backend/services/social-link-service.d.ts.map +1 -0
- package/dist/backend/services/social-link-service.js +121 -0
- package/dist/backend/services/social-link-service.js.map +1 -0
- package/dist/backend/services/totp.d.ts +11 -0
- package/dist/backend/services/totp.d.ts.map +1 -0
- package/dist/backend/services/totp.js +46 -0
- package/dist/backend/services/totp.js.map +1 -0
- package/dist/manifest.d.ts +184 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +264 -0
- package/dist/manifest.js.map +1 -0
- package/dist/migrations/20260611T140409_mfa_init.d.ts +22 -0
- package/dist/migrations/20260611T140409_mfa_init.d.ts.map +1 -0
- package/dist/migrations/20260611T140409_mfa_init.js +94 -0
- package/dist/migrations/20260611T140409_mfa_init.js.map +1 -0
- package/dist/migrations/index.d.ts +27 -0
- package/dist/migrations/index.d.ts.map +1 -0
- package/dist/migrations/index.js +29 -0
- package/dist/migrations/index.js.map +1 -0
- package/i18n/en.json +14 -0
- package/i18n/pl.json +14 -0
- package/package.json +102 -0
- package/tailwind.css +14 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { MFA_SETTING_CODES } from '@endora-commerce/contracts';
|
|
3
|
+
import { MfaOrganizationPolicy } from '../entities/mfa-organization-policy.entity.js';
|
|
4
|
+
export class MfaPolicyResolver {
|
|
5
|
+
settings;
|
|
6
|
+
emFactory;
|
|
7
|
+
resolveDefaultChannelId;
|
|
8
|
+
constructor(settings, emFactory,
|
|
9
|
+
/**
|
|
10
|
+
* Resolves the system-default sales-channel id for a storefront request
|
|
11
|
+
* that arrived without one. `settingsService.get` requires a real channel
|
|
12
|
+
* uuid or `null`, so a literal sentinel cannot be used.
|
|
13
|
+
*/
|
|
14
|
+
resolveDefaultChannelId = async () => null) {
|
|
15
|
+
this.settings = settings;
|
|
16
|
+
this.emFactory = emFactory;
|
|
17
|
+
this.resolveDefaultChannelId = resolveDefaultChannelId;
|
|
18
|
+
}
|
|
19
|
+
async resolve(subject, ctx) {
|
|
20
|
+
const isAdmin = subject.subjectType === 'admin';
|
|
21
|
+
// The admin surface has no storefront, so its four `mfa.admin.*` settings
|
|
22
|
+
// are platform-wide by nature: `null` reads `global_value ?? default_value`
|
|
23
|
+
// directly (feature 072, D-41 case c). It used to resolve the system-default
|
|
24
|
+
// *channel* and read there, then coerce a missing one to `''` — which is not
|
|
25
|
+
// a uuid, so the query threw in the driver and the `catch` below reported
|
|
26
|
+
// every policy as `false`, i.e. MFA quietly off.
|
|
27
|
+
//
|
|
28
|
+
// A storefront subject keeps its channel: `mfa.storefront.*` genuinely
|
|
29
|
+
// differs per storefront. With no request channel it falls back to the
|
|
30
|
+
// system default, and to platform-wide only if there is none.
|
|
31
|
+
const channelId = isAdmin
|
|
32
|
+
? null
|
|
33
|
+
: ctx.salesChannelId ?? (await this.resolveDefaultChannelId());
|
|
34
|
+
const codes = isAdmin
|
|
35
|
+
? {
|
|
36
|
+
enabled: MFA_SETTING_CODES.ADMIN_TOTP_ENABLED,
|
|
37
|
+
enforced: MFA_SETTING_CODES.ADMIN_TOTP_ENFORCED,
|
|
38
|
+
google: MFA_SETTING_CODES.ADMIN_GOOGLE_ENABLED,
|
|
39
|
+
microsoft: MFA_SETTING_CODES.ADMIN_MICROSOFT_ENABLED,
|
|
40
|
+
}
|
|
41
|
+
: {
|
|
42
|
+
enabled: MFA_SETTING_CODES.STOREFRONT_TOTP_ENABLED,
|
|
43
|
+
enforced: MFA_SETTING_CODES.STOREFRONT_TOTP_ENFORCED,
|
|
44
|
+
google: MFA_SETTING_CODES.STOREFRONT_GOOGLE_ENABLED,
|
|
45
|
+
microsoft: MFA_SETTING_CODES.STOREFRONT_MICROSOFT_ENABLED,
|
|
46
|
+
};
|
|
47
|
+
const [totpEnabledRaw, totpEnforcedRaw, googleEnabled, microsoftEnabled] = await Promise.all([
|
|
48
|
+
this.readBool(codes.enabled, channelId),
|
|
49
|
+
this.readBool(codes.enforced, channelId),
|
|
50
|
+
this.readBool(codes.google, channelId),
|
|
51
|
+
this.readBool(codes.microsoft, channelId),
|
|
52
|
+
]);
|
|
53
|
+
// Organization enforcement is additive (customers only).
|
|
54
|
+
let orgEnforced = false;
|
|
55
|
+
if (!isAdmin && ctx.organizationId) {
|
|
56
|
+
orgEnforced = await this.readOrgEnforcement(ctx.organizationId);
|
|
57
|
+
}
|
|
58
|
+
const totpEnforced = totpEnforcedRaw || orgEnforced;
|
|
59
|
+
// Invariant FR-016: enforcement implies enablement.
|
|
60
|
+
const totpEnabled = totpEnabledRaw || totpEnforced;
|
|
61
|
+
return { totpEnabled, totpEnforced, googleEnabled, microsoftEnabled };
|
|
62
|
+
}
|
|
63
|
+
async readBool(code, channelId) {
|
|
64
|
+
try {
|
|
65
|
+
return await this.settings.get(code, channelId, z.boolean());
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async readOrgEnforcement(organizationId) {
|
|
72
|
+
try {
|
|
73
|
+
const em = this.emFactory();
|
|
74
|
+
const policy = await em.findOne(MfaOrganizationPolicy, { organizationId });
|
|
75
|
+
return policy?.enforceTotp ?? false;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
//# sourceMappingURL=mfa-policy-resolver.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mfa-policy-resolver.js","sourceRoot":"","sources":["../../../src/backend/services/mfa-policy-resolver.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Federated sign-in provider port (feature 042, US4/US5). The `mfa` routes
|
|
3
|
+
* depend on this interface; production wires the `openid-client`-backed
|
|
4
|
+
* implementation below, tests inject a deterministic fake. Provider client
|
|
5
|
+
* id/secret come from backend config/env — never the Settings table (R2).
|
|
6
|
+
*/
|
|
7
|
+
export type OAuthProviderName = 'google' | 'microsoft';
|
|
8
|
+
export interface OAuthIdentity {
|
|
9
|
+
provider: OAuthProviderName;
|
|
10
|
+
/** Stable provider subject (`sub` claim). */
|
|
11
|
+
sub: string;
|
|
12
|
+
email: string;
|
|
13
|
+
emailVerified: boolean;
|
|
14
|
+
}
|
|
15
|
+
export interface OAuthProviderPort {
|
|
16
|
+
/** Whether the provider has client credentials configured. */
|
|
17
|
+
isEnabled(provider: OAuthProviderName): boolean;
|
|
18
|
+
buildAuthorizationUrl(provider: OAuthProviderName, args: {
|
|
19
|
+
state: string;
|
|
20
|
+
codeVerifier: string;
|
|
21
|
+
nonce: string;
|
|
22
|
+
redirectUri: string;
|
|
23
|
+
}): Promise<string>;
|
|
24
|
+
exchangeCode(provider: OAuthProviderName, args: {
|
|
25
|
+
code: string;
|
|
26
|
+
state: string;
|
|
27
|
+
codeVerifier: string;
|
|
28
|
+
nonce: string;
|
|
29
|
+
redirectUri: string;
|
|
30
|
+
}): Promise<OAuthIdentity>;
|
|
31
|
+
}
|
|
32
|
+
export interface OAuthProviderConfig {
|
|
33
|
+
google?: {
|
|
34
|
+
clientId: string;
|
|
35
|
+
clientSecret: string;
|
|
36
|
+
} | undefined;
|
|
37
|
+
microsoft?: {
|
|
38
|
+
clientId: string;
|
|
39
|
+
clientSecret: string;
|
|
40
|
+
tenant?: string | undefined;
|
|
41
|
+
} | undefined;
|
|
42
|
+
}
|
|
43
|
+
/** Builds the provider config from environment, or `null` when nothing is set. */
|
|
44
|
+
export declare function readOAuthConfigFromEnv(env?: NodeJS.ProcessEnv): OAuthProviderConfig;
|
|
45
|
+
export declare class OpenIdOAuthProvider implements OAuthProviderPort {
|
|
46
|
+
private readonly cfg;
|
|
47
|
+
private readonly configs;
|
|
48
|
+
constructor(cfg: OAuthProviderConfig);
|
|
49
|
+
isEnabled(provider: OAuthProviderName): boolean;
|
|
50
|
+
private config;
|
|
51
|
+
buildAuthorizationUrl(provider: OAuthProviderName, args: {
|
|
52
|
+
state: string;
|
|
53
|
+
codeVerifier: string;
|
|
54
|
+
nonce: string;
|
|
55
|
+
redirectUri: string;
|
|
56
|
+
}): Promise<string>;
|
|
57
|
+
exchangeCode(provider: OAuthProviderName, args: {
|
|
58
|
+
code: string;
|
|
59
|
+
state: string;
|
|
60
|
+
codeVerifier: string;
|
|
61
|
+
nonce: string;
|
|
62
|
+
redirectUri: string;
|
|
63
|
+
}): Promise<OAuthIdentity>;
|
|
64
|
+
}
|
|
65
|
+
//# sourceMappingURL=oauth-provider-service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"oauth-provider-service.d.ts","sourceRoot":"","sources":["../../../src/backend/services/oauth-provider-service.ts"],"names":[],"mappings":"AAEA;;;;;GAKG;AACH,MAAM,MAAM,iBAAiB,GAAG,QAAQ,GAAG,WAAW,CAAC;AAEvD,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,iBAAiB,CAAC;IAC5B,6CAA6C;IAC7C,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,EAAE,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,iBAAiB;IAChC,8DAA8D;IAC9D,SAAS,CAAC,QAAQ,EAAE,iBAAiB,GAAG,OAAO,CAAC;IAChD,qBAAqB,CACnB,QAAQ,EAAE,iBAAiB,EAC3B,IAAI,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,YAAY,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,GAChF,OAAO,CAAC,MAAM,CAAC,CAAC;IACnB,YAAY,CACV,QAAQ,EAAE,iBAAiB,EAC3B,IAAI,EAAE;QACJ,IAAI,EAAE,MAAM,CAAC;QACb,KAAK,EAAE,MAAM,CAAC;QACd,YAAY,EAAE,MAAM,CAAC;QACrB,KAAK,EAAE,MAAM,CAAC;QACd,WAAW,EAAE,MAAM,CAAC;KACrB,GACA,OAAO,CAAC,aAAa,CAAC,CAAC;CAC3B;AAED,MAAM,WAAW,mBAAmB;IAClC,MAAM,CAAC,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,YAAY,EAAE,MAAM,CAAA;KAAE,GAAG,SAAS,CAAC;IAChE,SAAS,CAAC,EACN;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,YAAY,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;KAAE,GACvE,SAAS,CAAC;CACf;AAED,kFAAkF;AAClF,wBAAgB,sBAAsB,CAAC,GAAG,oBAAc,GAAG,mBAAmB,CAgB7E;AAED,qBAAa,mBAAoB,YAAW,iBAAiB;IAG/C,OAAO,CAAC,QAAQ,CAAC,GAAG;IAFhC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAoD;gBAE/C,GAAG,EAAE,mBAAmB;IAErD,SAAS,CAAC,QAAQ,EAAE,iBAAiB,GAAG,OAAO;YAIjC,MAAM;IAwBd,qBAAqB,CACzB,QAAQ,EAAE,iBAAiB,EAC3B,IAAI,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,YAAY,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,GAChF,OAAO,CAAC,MAAM,CAAC;IAcZ,YAAY,CAChB,QAAQ,EAAE,iBAAiB,EAC3B,IAAI,EAAE;QACJ,IAAI,EAAE,MAAM,CAAC;QACb,KAAK,EAAE,MAAM,CAAC;QACd,YAAY,EAAE,MAAM,CAAC;QACrB,KAAK,EAAE,MAAM,CAAC;QACd,WAAW,EAAE,MAAM,CAAC;KACrB,GACA,OAAO,CAAC,aAAa,CAAC;CAmB1B"}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import * as oidc from 'openid-client';
|
|
2
|
+
/** Builds the provider config from environment, or `null` when nothing is set. */
|
|
3
|
+
export function readOAuthConfigFromEnv(env = process.env) {
|
|
4
|
+
const cfg = {};
|
|
5
|
+
if (env['MFA_OAUTH_GOOGLE_CLIENT_ID'] && env['MFA_OAUTH_GOOGLE_CLIENT_SECRET']) {
|
|
6
|
+
cfg.google = {
|
|
7
|
+
clientId: env['MFA_OAUTH_GOOGLE_CLIENT_ID'],
|
|
8
|
+
clientSecret: env['MFA_OAUTH_GOOGLE_CLIENT_SECRET'],
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
if (env['MFA_OAUTH_MICROSOFT_CLIENT_ID'] && env['MFA_OAUTH_MICROSOFT_CLIENT_SECRET']) {
|
|
12
|
+
cfg.microsoft = {
|
|
13
|
+
clientId: env['MFA_OAUTH_MICROSOFT_CLIENT_ID'],
|
|
14
|
+
clientSecret: env['MFA_OAUTH_MICROSOFT_CLIENT_SECRET'],
|
|
15
|
+
tenant: env['MFA_OAUTH_MICROSOFT_TENANT'] ?? 'common',
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
return cfg;
|
|
19
|
+
}
|
|
20
|
+
export class OpenIdOAuthProvider {
|
|
21
|
+
cfg;
|
|
22
|
+
configs = new Map();
|
|
23
|
+
constructor(cfg) {
|
|
24
|
+
this.cfg = cfg;
|
|
25
|
+
}
|
|
26
|
+
isEnabled(provider) {
|
|
27
|
+
return provider === 'google' ? Boolean(this.cfg.google) : Boolean(this.cfg.microsoft);
|
|
28
|
+
}
|
|
29
|
+
async config(provider) {
|
|
30
|
+
const cached = this.configs.get(provider);
|
|
31
|
+
if (cached)
|
|
32
|
+
return cached;
|
|
33
|
+
if (provider === 'google') {
|
|
34
|
+
if (!this.cfg.google)
|
|
35
|
+
throw new Error('Google OAuth is not configured.');
|
|
36
|
+
const config = await oidc.discovery(new URL('https://accounts.google.com'), this.cfg.google.clientId, this.cfg.google.clientSecret);
|
|
37
|
+
this.configs.set(provider, config);
|
|
38
|
+
return config;
|
|
39
|
+
}
|
|
40
|
+
if (!this.cfg.microsoft)
|
|
41
|
+
throw new Error('Microsoft OAuth is not configured.');
|
|
42
|
+
const tenant = this.cfg.microsoft.tenant ?? 'common';
|
|
43
|
+
const config = await oidc.discovery(new URL(`https://login.microsoftonline.com/${tenant}/v2.0`), this.cfg.microsoft.clientId, this.cfg.microsoft.clientSecret);
|
|
44
|
+
this.configs.set(provider, config);
|
|
45
|
+
return config;
|
|
46
|
+
}
|
|
47
|
+
async buildAuthorizationUrl(provider, args) {
|
|
48
|
+
const config = await this.config(provider);
|
|
49
|
+
const codeChallenge = await oidc.calculatePKCECodeChallenge(args.codeVerifier);
|
|
50
|
+
const url = oidc.buildAuthorizationUrl(config, {
|
|
51
|
+
redirect_uri: args.redirectUri,
|
|
52
|
+
scope: 'openid email profile',
|
|
53
|
+
code_challenge: codeChallenge,
|
|
54
|
+
code_challenge_method: 'S256',
|
|
55
|
+
state: args.state,
|
|
56
|
+
nonce: args.nonce,
|
|
57
|
+
});
|
|
58
|
+
return url.href;
|
|
59
|
+
}
|
|
60
|
+
async exchangeCode(provider, args) {
|
|
61
|
+
const config = await this.config(provider);
|
|
62
|
+
const currentUrl = new URL(args.redirectUri);
|
|
63
|
+
currentUrl.searchParams.set('code', args.code);
|
|
64
|
+
currentUrl.searchParams.set('state', args.state);
|
|
65
|
+
const tokens = await oidc.authorizationCodeGrant(config, currentUrl, {
|
|
66
|
+
pkceCodeVerifier: args.codeVerifier,
|
|
67
|
+
expectedNonce: args.nonce,
|
|
68
|
+
expectedState: args.state,
|
|
69
|
+
idTokenExpected: true,
|
|
70
|
+
});
|
|
71
|
+
const claims = tokens.claims();
|
|
72
|
+
return {
|
|
73
|
+
provider,
|
|
74
|
+
sub: String(claims?.sub ?? ''),
|
|
75
|
+
email: typeof claims?.['email'] === 'string' ? claims['email'] : '',
|
|
76
|
+
emailVerified: claims?.['email_verified'] === true,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
//# sourceMappingURL=oauth-provider-service.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"oauth-provider-service.js","sourceRoot":"","sources":["../../../src/backend/services/oauth-provider-service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,IAAI,MAAM,eAAe,CAAC;AA4CtC,kFAAkF;AAClF,MAAM,UAAU,sBAAsB,CAAC,GAAG,GAAG,OAAO,CAAC,GAAG;IACtD,MAAM,GAAG,GAAwB,EAAE,CAAC;IACpC,IAAI,GAAG,CAAC,4BAA4B,CAAC,IAAI,GAAG,CAAC,gCAAgC,CAAC,EAAE,CAAC;QAC/E,GAAG,CAAC,MAAM,GAAG;YACX,QAAQ,EAAE,GAAG,CAAC,4BAA4B,CAAC;YAC3C,YAAY,EAAE,GAAG,CAAC,gCAAgC,CAAC;SACpD,CAAC;IACJ,CAAC;IACD,IAAI,GAAG,CAAC,+BAA+B,CAAC,IAAI,GAAG,CAAC,mCAAmC,CAAC,EAAE,CAAC;QACrF,GAAG,CAAC,SAAS,GAAG;YACd,QAAQ,EAAE,GAAG,CAAC,+BAA+B,CAAC;YAC9C,YAAY,EAAE,GAAG,CAAC,mCAAmC,CAAC;YACtD,MAAM,EAAE,GAAG,CAAC,4BAA4B,CAAC,IAAI,QAAQ;SACtD,CAAC;IACJ,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC;AAED,MAAM,OAAO,mBAAmB;IAGD;IAFZ,OAAO,GAAG,IAAI,GAAG,EAAyC,CAAC;IAE5E,YAA6B,GAAwB;QAAxB,QAAG,GAAH,GAAG,CAAqB;IAAG,CAAC;IAEzD,SAAS,CAAC,QAA2B;QACnC,OAAO,QAAQ,KAAK,QAAQ,CAAC,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;IACxF,CAAC;IAEO,KAAK,CAAC,MAAM,CAAC,QAA2B;QAC9C,MAAM,MAAM,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QAC1C,IAAI,MAAM;YAAE,OAAO,MAAM,CAAC;QAC1B,IAAI,QAAQ,KAAK,QAAQ,EAAE,CAAC;YAC1B,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM;gBAAE,MAAM,IAAI,KAAK,CAAC,iCAAiC,CAAC,CAAC;YACzE,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,SAAS,CACjC,IAAI,GAAG,CAAC,6BAA6B,CAAC,EACtC,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,QAAQ,EACxB,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,YAAY,CAC7B,CAAC;YACF,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;YACnC,OAAO,MAAM,CAAC;QAChB,CAAC;QACD,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,SAAS;YAAE,MAAM,IAAI,KAAK,CAAC,oCAAoC,CAAC,CAAC;QAC/E,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,SAAS,CAAC,MAAM,IAAI,QAAQ,CAAC;QACrD,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,SAAS,CACjC,IAAI,GAAG,CAAC,qCAAqC,MAAM,OAAO,CAAC,EAC3D,IAAI,CAAC,GAAG,CAAC,SAAS,CAAC,QAAQ,EAC3B,IAAI,CAAC,GAAG,CAAC,SAAS,CAAC,YAAY,CAChC,CAAC;QACF,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;QACnC,OAAO,MAAM,CAAC;IAChB,CAAC;IAED,KAAK,CAAC,qBAAqB,CACzB,QAA2B,EAC3B,IAAiF;QAEjF,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;QAC3C,MAAM,aAAa,GAAG,MAAM,IAAI,CAAC,0BAA0B,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC;QAC/E,MAAM,GAAG,GAAG,IAAI,CAAC,qBAAqB,CAAC,MAAM,EAAE;YAC7C,YAAY,EAAE,IAAI,CAAC,WAAW;YAC9B,KAAK,EAAE,sBAAsB;YAC7B,cAAc,EAAE,aAAa;YAC7B,qBAAqB,EAAE,MAAM;YAC7B,KAAK,EAAE,IAAI,CAAC,KAAK;YACjB,KAAK,EAAE,IAAI,CAAC,KAAK;SAClB,CAAC,CAAC;QACH,OAAO,GAAG,CAAC,IAAI,CAAC;IAClB,CAAC;IAED,KAAK,CAAC,YAAY,CAChB,QAA2B,EAC3B,IAMC;QAED,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;QAC3C,MAAM,UAAU,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;QAC7C,UAAU,CAAC,YAAY,CAAC,GAAG,CAAC,MAAM,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC;QAC/C,UAAU,CAAC,YAAY,CAAC,GAAG,CAAC,OAAO,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC;QACjD,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,sBAAsB,CAAC,MAAM,EAAE,UAAU,EAAE;YACnE,gBAAgB,EAAE,IAAI,CAAC,YAAY;YACnC,aAAa,EAAE,IAAI,CAAC,KAAK;YACzB,aAAa,EAAE,IAAI,CAAC,KAAK;YACzB,eAAe,EAAE,IAAI;SACtB,CAAC,CAAC;QACH,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC;QAC/B,OAAO;YACL,QAAQ;YACR,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,IAAI,EAAE,CAAC;YAC9B,KAAK,EAAE,OAAO,MAAM,EAAE,CAAC,OAAO,CAAC,KAAK,QAAQ,CAAC,CAAC,CAAE,MAAM,CAAC,OAAO,CAAY,CAAC,CAAC,CAAC,EAAE;YAC/E,aAAa,EAAE,MAAM,EAAE,CAAC,gBAAgB,CAAC,KAAK,IAAI;SACnD,CAAC;IACJ,CAAC;CACF"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recovery (backup) code helpers (feature 042). Codes are shown once at
|
|
3
|
+
* activation/regeneration; only their SHA-256 hashes are stored. Matching is
|
|
4
|
+
* case-insensitive and whitespace-tolerant (the hash normalizes the input).
|
|
5
|
+
*/
|
|
6
|
+
export declare const RECOVERY_CODE_COUNT = 10;
|
|
7
|
+
export declare function generateRecoveryCodes(count?: number): string[];
|
|
8
|
+
export declare function hashRecoveryCode(code: string): string;
|
|
9
|
+
export declare function normalizeRecoveryCode(code: string): string;
|
|
10
|
+
//# sourceMappingURL=recovery-codes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"recovery-codes.d.ts","sourceRoot":"","sources":["../../../src/backend/services/recovery-codes.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,KAAK,CAAC;AAGtC,wBAAgB,qBAAqB,CAAC,KAAK,GAAE,MAA4B,GAAG,MAAM,EAAE,CAInF;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAErD;AAED,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE1D"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'crypto';
|
|
2
|
+
/**
|
|
3
|
+
* Recovery (backup) code helpers (feature 042). Codes are shown once at
|
|
4
|
+
* activation/regeneration; only their SHA-256 hashes are stored. Matching is
|
|
5
|
+
* case-insensitive and whitespace-tolerant (the hash normalizes the input).
|
|
6
|
+
*/
|
|
7
|
+
export const RECOVERY_CODE_COUNT = 10;
|
|
8
|
+
const CODE_BYTES = 5; // → 10 hex chars
|
|
9
|
+
export function generateRecoveryCodes(count = RECOVERY_CODE_COUNT) {
|
|
10
|
+
return Array.from({ length: count }, () => randomBytes(CODE_BYTES).toString('hex').toUpperCase());
|
|
11
|
+
}
|
|
12
|
+
export function hashRecoveryCode(code) {
|
|
13
|
+
return createHash('sha256').update(normalizeRecoveryCode(code)).digest('hex');
|
|
14
|
+
}
|
|
15
|
+
export function normalizeRecoveryCode(code) {
|
|
16
|
+
return code.replace(/\s+/g, '').toUpperCase();
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=recovery-codes.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"recovery-codes.js","sourceRoot":"","sources":["../../../src/backend/services/recovery-codes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,QAAQ,CAAC;AAEjD;;;;GAIG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG,EAAE,CAAC;AACtC,MAAM,UAAU,GAAG,CAAC,CAAC,CAAC,iBAAiB;AAEvC,MAAM,UAAU,qBAAqB,CAAC,QAAgB,mBAAmB;IACvE,OAAO,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,KAAK,EAAE,EAAE,GAAG,EAAE,CACxC,WAAW,CAAC,UAAU,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,WAAW,EAAE,CACtD,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,gBAAgB,CAAC,IAAY;IAC3C,OAAO,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,qBAAqB,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;AAChF,CAAC;AAED,MAAM,UAAU,qBAAqB,CAAC,IAAY;IAChD,OAAO,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,WAAW,EAAE,CAAC;AAChD,CAAC"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface EncryptedSecret {
|
|
2
|
+
ciphertext: Buffer;
|
|
3
|
+
iv: Buffer;
|
|
4
|
+
authTag: Buffer;
|
|
5
|
+
}
|
|
6
|
+
export declare class SecretCipher {
|
|
7
|
+
private readonly key;
|
|
8
|
+
constructor(keyBase64: string);
|
|
9
|
+
/** Encrypt a plaintext secret (the base32 TOTP secret). */
|
|
10
|
+
encrypt(plaintext: string): EncryptedSecret;
|
|
11
|
+
/** Decrypt; throws if the ciphertext or tag has been tampered with. */
|
|
12
|
+
decrypt(secret: EncryptedSecret): string;
|
|
13
|
+
}
|
|
14
|
+
/** Timing-safe equality for two same-length hex/base64 strings. */
|
|
15
|
+
export declare function safeEqual(a: string, b: string): boolean;
|
|
16
|
+
//# sourceMappingURL=secret-cipher.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"secret-cipher.d.ts","sourceRoot":"","sources":["../../../src/backend/services/secret-cipher.ts"],"names":[],"mappings":"AAmBA,MAAM,WAAW,eAAe;IAC9B,UAAU,EAAE,MAAM,CAAC;IACnB,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAS;gBAEjB,SAAS,EAAE,MAAM;IAU7B,2DAA2D;IAC3D,OAAO,CAAC,SAAS,EAAE,MAAM,GAAG,eAAe;IAW3C,uEAAuE;IACvE,OAAO,CAAC,MAAM,EAAE,eAAe,GAAG,MAAM;CASzC;AAED,mEAAmE;AACnE,wBAAgB,SAAS,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,OAAO,CAKvD"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes, timingSafeEqual, } from 'crypto';
|
|
2
|
+
/**
|
|
3
|
+
* Authenticated encryption for the TOTP secret at rest (feature 042, R7).
|
|
4
|
+
*
|
|
5
|
+
* AES-256-GCM via Node's built-in crypto — no new dependency. The 32-byte key
|
|
6
|
+
* comes from `MFA_SECRET_ENCRYPTION_KEY` (base64). Each enrolment row stores
|
|
7
|
+
* its own random 12-byte IV and the GCM auth tag, so a leaked database does
|
|
8
|
+
* not yield working TOTP secrets.
|
|
9
|
+
*/
|
|
10
|
+
const ALGORITHM = 'aes-256-gcm';
|
|
11
|
+
const IV_LENGTH = 12;
|
|
12
|
+
const KEY_LENGTH = 32;
|
|
13
|
+
export class SecretCipher {
|
|
14
|
+
key;
|
|
15
|
+
constructor(keyBase64) {
|
|
16
|
+
const key = Buffer.from(keyBase64, 'base64');
|
|
17
|
+
if (key.length !== KEY_LENGTH) {
|
|
18
|
+
throw new Error(`MFA_SECRET_ENCRYPTION_KEY must decode to ${KEY_LENGTH} bytes (got ${key.length}).`);
|
|
19
|
+
}
|
|
20
|
+
this.key = key;
|
|
21
|
+
}
|
|
22
|
+
/** Encrypt a plaintext secret (the base32 TOTP secret). */
|
|
23
|
+
encrypt(plaintext) {
|
|
24
|
+
const iv = randomBytes(IV_LENGTH);
|
|
25
|
+
const cipher = createCipheriv(ALGORITHM, this.key, iv);
|
|
26
|
+
const ciphertext = Buffer.concat([
|
|
27
|
+
cipher.update(plaintext, 'utf8'),
|
|
28
|
+
cipher.final(),
|
|
29
|
+
]);
|
|
30
|
+
const authTag = cipher.getAuthTag();
|
|
31
|
+
return { ciphertext, iv, authTag };
|
|
32
|
+
}
|
|
33
|
+
/** Decrypt; throws if the ciphertext or tag has been tampered with. */
|
|
34
|
+
decrypt(secret) {
|
|
35
|
+
const decipher = createDecipheriv(ALGORITHM, this.key, secret.iv);
|
|
36
|
+
decipher.setAuthTag(secret.authTag);
|
|
37
|
+
const plaintext = Buffer.concat([
|
|
38
|
+
decipher.update(secret.ciphertext),
|
|
39
|
+
decipher.final(),
|
|
40
|
+
]);
|
|
41
|
+
return plaintext.toString('utf8');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Timing-safe equality for two same-length hex/base64 strings. */
|
|
45
|
+
export function safeEqual(a, b) {
|
|
46
|
+
const bufA = Buffer.from(a);
|
|
47
|
+
const bufB = Buffer.from(b);
|
|
48
|
+
if (bufA.length !== bufB.length)
|
|
49
|
+
return false;
|
|
50
|
+
return timingSafeEqual(bufA, bufB);
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=secret-cipher.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"secret-cipher.js","sourceRoot":"","sources":["../../../src/backend/services/secret-cipher.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,cAAc,EACd,gBAAgB,EAChB,WAAW,EACX,eAAe,GAChB,MAAM,QAAQ,CAAC;AAEhB;;;;;;;GAOG;AACH,MAAM,SAAS,GAAG,aAAa,CAAC;AAChC,MAAM,SAAS,GAAG,EAAE,CAAC;AACrB,MAAM,UAAU,GAAG,EAAE,CAAC;AAQtB,MAAM,OAAO,YAAY;IACN,GAAG,CAAS;IAE7B,YAAY,SAAiB;QAC3B,MAAM,GAAG,GAAG,MAAM,CAAC,IAAI,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;QAC7C,IAAI,GAAG,CAAC,MAAM,KAAK,UAAU,EAAE,CAAC;YAC9B,MAAM,IAAI,KAAK,CACb,4CAA4C,UAAU,eAAe,GAAG,CAAC,MAAM,IAAI,CACpF,CAAC;QACJ,CAAC;QACD,IAAI,CAAC,GAAG,GAAG,GAAG,CAAC;IACjB,CAAC;IAED,2DAA2D;IAC3D,OAAO,CAAC,SAAiB;QACvB,MAAM,EAAE,GAAG,WAAW,CAAC,SAAS,CAAC,CAAC;QAClC,MAAM,MAAM,GAAG,cAAc,CAAC,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;QACvD,MAAM,UAAU,GAAG,MAAM,CAAC,MAAM,CAAC;YAC/B,MAAM,CAAC,MAAM,CAAC,SAAS,EAAE,MAAM,CAAC;YAChC,MAAM,CAAC,KAAK,EAAE;SACf,CAAC,CAAC;QACH,MAAM,OAAO,GAAG,MAAM,CAAC,UAAU,EAAE,CAAC;QACpC,OAAO,EAAE,UAAU,EAAE,EAAE,EAAE,OAAO,EAAE,CAAC;IACrC,CAAC;IAED,uEAAuE;IACvE,OAAO,CAAC,MAAuB;QAC7B,MAAM,QAAQ,GAAG,gBAAgB,CAAC,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,MAAM,CAAC,EAAE,CAAC,CAAC;QAClE,QAAQ,CAAC,UAAU,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;QACpC,MAAM,SAAS,GAAG,MAAM,CAAC,MAAM,CAAC;YAC9B,QAAQ,CAAC,MAAM,CAAC,MAAM,CAAC,UAAU,CAAC;YAClC,QAAQ,CAAC,KAAK,EAAE;SACjB,CAAC,CAAC;QACH,OAAO,SAAS,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;IACpC,CAAC;CACF;AAED,mEAAmE;AACnE,MAAM,UAAU,SAAS,CAAC,CAAS,EAAE,CAAS;IAC5C,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAC5B,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAC5B,IAAI,IAAI,CAAC,MAAM,KAAK,IAAI,CAAC,MAAM;QAAE,OAAO,KAAK,CAAC;IAC9C,OAAO,eAAe,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;AACrC,CAAC"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql';
|
|
2
|
+
import type { AuditPort } from '@endora-commerce/platform/kernel';
|
|
3
|
+
import type { OAuthIdentity } from './oauth-provider-service.js';
|
|
4
|
+
/**
|
|
5
|
+
* Resolves a federated identity to a platform account and records the link
|
|
6
|
+
* (feature 042, R6). Account find/create is delegated to injected ports so the
|
|
7
|
+
* `mfa` module never reaches into the customer/admin internals (Principle I):
|
|
8
|
+
* - Storefront: match by verified email, else auto-create a standalone
|
|
9
|
+
* customer (when registration-without-organization is allowed).
|
|
10
|
+
* - Admin: match an existing admin user only — never auto-create.
|
|
11
|
+
*/
|
|
12
|
+
export interface SocialIdentityDeps {
|
|
13
|
+
resolveCustomerByEmail: (email: string) => Promise<{
|
|
14
|
+
id: string;
|
|
15
|
+
} | null>;
|
|
16
|
+
/** Returns the new account id, or null when standalone registration is off. */
|
|
17
|
+
autoCreateCustomer: (email: string) => Promise<{
|
|
18
|
+
id: string;
|
|
19
|
+
} | null>;
|
|
20
|
+
resolveAdminByEmail: (email: string) => Promise<{
|
|
21
|
+
id: string;
|
|
22
|
+
} | null>;
|
|
23
|
+
}
|
|
24
|
+
export type SocialSignInResult = {
|
|
25
|
+
ok: true;
|
|
26
|
+
subjectId: string;
|
|
27
|
+
} | {
|
|
28
|
+
ok: false;
|
|
29
|
+
reason: 'unverified' | 'registration_required' | 'no_account';
|
|
30
|
+
};
|
|
31
|
+
export declare class SocialIdentityService {
|
|
32
|
+
private readonly emFactory;
|
|
33
|
+
private readonly deps;
|
|
34
|
+
private readonly auditLogService;
|
|
35
|
+
constructor(emFactory: () => EntityManager, deps: SocialIdentityDeps, auditLogService: AuditPort);
|
|
36
|
+
signInCustomer(identity: OAuthIdentity): Promise<SocialSignInResult>;
|
|
37
|
+
signInAdmin(identity: OAuthIdentity): Promise<SocialSignInResult>;
|
|
38
|
+
private upsertLink;
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=social-identity-service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"social-identity-service.d.ts","sourceRoot":"","sources":["../../../src/backend/services/social-identity-service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kCAAkC,CAAC;AAElE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAC;AAEjE;;;;;;;GAOG;AACH,MAAM,WAAW,kBAAkB;IACjC,sBAAsB,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC;QAAE,EAAE,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC,CAAC;IAC1E,+EAA+E;IAC/E,kBAAkB,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC;QAAE,EAAE,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC,CAAC;IACtE,mBAAmB,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC;QAAE,EAAE,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC,CAAC;CACxE;AAED,MAAM,MAAM,kBAAkB,GAC1B;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,GAC/B;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,YAAY,GAAG,uBAAuB,GAAG,YAAY,CAAA;CAAE,CAAC;AAEjF,qBAAa,qBAAqB;IAE9B,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,eAAe;gBAFf,SAAS,EAAE,MAAM,aAAa,EAC9B,IAAI,EAAE,kBAAkB,EACxB,eAAe,EAAE,SAAS;IAGvC,cAAc,CAAC,QAAQ,EAAE,aAAa,GAAG,OAAO,CAAC,kBAAkB,CAAC;IAmBpE,WAAW,CAAC,QAAQ,EAAE,aAAa,GAAG,OAAO,CAAC,kBAAkB,CAAC;YAazD,UAAU;CA0BzB"}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { MfaSocialIdentity } from '../entities/mfa-social-identity.entity.js';
|
|
2
|
+
export class SocialIdentityService {
|
|
3
|
+
emFactory;
|
|
4
|
+
deps;
|
|
5
|
+
auditLogService;
|
|
6
|
+
constructor(emFactory, deps, auditLogService) {
|
|
7
|
+
this.emFactory = emFactory;
|
|
8
|
+
this.deps = deps;
|
|
9
|
+
this.auditLogService = auditLogService;
|
|
10
|
+
}
|
|
11
|
+
async signInCustomer(identity) {
|
|
12
|
+
if (!identity.emailVerified || !identity.email)
|
|
13
|
+
return { ok: false, reason: 'unverified' };
|
|
14
|
+
const existing = await this.deps.resolveCustomerByEmail(identity.email);
|
|
15
|
+
let account = existing;
|
|
16
|
+
let created = false;
|
|
17
|
+
if (!account) {
|
|
18
|
+
account = await this.deps.autoCreateCustomer(identity.email);
|
|
19
|
+
if (!account)
|
|
20
|
+
return { ok: false, reason: 'registration_required' };
|
|
21
|
+
created = true;
|
|
22
|
+
}
|
|
23
|
+
await this.upsertLink('customer', account.id, identity);
|
|
24
|
+
await this.auditLogService.record({
|
|
25
|
+
action: created ? 'mfa.social_account_created' : 'mfa.social_linked',
|
|
26
|
+
objectType: 'customer_account',
|
|
27
|
+
objectId: account.id,
|
|
28
|
+
});
|
|
29
|
+
return { ok: true, subjectId: account.id };
|
|
30
|
+
}
|
|
31
|
+
async signInAdmin(identity) {
|
|
32
|
+
if (!identity.emailVerified || !identity.email)
|
|
33
|
+
return { ok: false, reason: 'unverified' };
|
|
34
|
+
const account = await this.deps.resolveAdminByEmail(identity.email);
|
|
35
|
+
if (!account)
|
|
36
|
+
return { ok: false, reason: 'no_account' };
|
|
37
|
+
await this.upsertLink('admin', account.id, identity);
|
|
38
|
+
await this.auditLogService.record({
|
|
39
|
+
action: 'mfa.social_linked',
|
|
40
|
+
objectType: 'admin_user',
|
|
41
|
+
objectId: account.id,
|
|
42
|
+
});
|
|
43
|
+
return { ok: true, subjectId: account.id };
|
|
44
|
+
}
|
|
45
|
+
async upsertLink(subjectType, subjectId, identity) {
|
|
46
|
+
const em = this.emFactory();
|
|
47
|
+
let link = await em.findOne(MfaSocialIdentity, {
|
|
48
|
+
provider: identity.provider,
|
|
49
|
+
providerSubject: identity.sub,
|
|
50
|
+
});
|
|
51
|
+
if (!link) {
|
|
52
|
+
link = em.create(MfaSocialIdentity, {
|
|
53
|
+
subjectType,
|
|
54
|
+
subjectId,
|
|
55
|
+
provider: identity.provider,
|
|
56
|
+
providerSubject: identity.sub,
|
|
57
|
+
email: identity.email,
|
|
58
|
+
lastUsedAt: new Date(),
|
|
59
|
+
});
|
|
60
|
+
em.persist(link);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
link.email = identity.email;
|
|
64
|
+
link.lastUsedAt = new Date();
|
|
65
|
+
}
|
|
66
|
+
await em.flush();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=social-identity-service.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"social-identity-service.js","sourceRoot":"","sources":["../../../src/backend/services/social-identity-service.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql';
|
|
2
|
+
import type { CustomerPasswordStatePort, MfaSocialLinkSummary, MfaSocialProvider, MfaSubjectRef } from '@endora-commerce/contracts';
|
|
3
|
+
import type { CommandBus } from '@endora-commerce/platform/commands';
|
|
4
|
+
/**
|
|
5
|
+
* The account-holder's view of their federated identities, and the one write
|
|
6
|
+
* that removes one (issue #194).
|
|
7
|
+
*
|
|
8
|
+
* Separate from {@link SocialIdentityService}, which resolves an incoming
|
|
9
|
+
* provider identity to an account at sign-in and therefore needs the account
|
|
10
|
+
* resolvers and a configured provider. This surface needs neither: the links a
|
|
11
|
+
* subject already holds stay visible and severable after an operator switches a
|
|
12
|
+
* provider off, which is exactly when somebody wants to look at them.
|
|
13
|
+
*
|
|
14
|
+
* ## Why an unlink can be refused
|
|
15
|
+
*
|
|
16
|
+
* A customer auto-created by a federated sign-in is given a random password at
|
|
17
|
+
* signup (`customer_accounts/backend.ts`) — one nobody was ever told. So
|
|
18
|
+
* `password_hash is not null` is true for such an account and answers a
|
|
19
|
+
* different question from the one that matters here, which is whether the
|
|
20
|
+
* holder has a credential they can actually use.
|
|
21
|
+
*
|
|
22
|
+
* Issue #194 could not ask that question and therefore refused the **last**
|
|
23
|
+
* link of every account, absolutely. Issue #222 added the datum —
|
|
24
|
+
* `customer_accounts.password_set_at`, read here over
|
|
25
|
+
* {@link CustomerPasswordStatePort} — so the rule is now the narrower and
|
|
26
|
+
* truthful one: refuse the last link **only** while the account has no password
|
|
27
|
+
* on record. Where another link remains, or a password has been set, the
|
|
28
|
+
* removal goes through. An active TOTP enrolment still does not unblock it: a
|
|
29
|
+
* second factor is not a first one, and nobody signs in with TOTP alone.
|
|
30
|
+
*
|
|
31
|
+
* That is also what makes the sentence at the seam an instruction again. It
|
|
32
|
+
* told the holder to set a password first, then said plainly that there was no
|
|
33
|
+
* way out (!723), because with the rule counting links the instruction named a
|
|
34
|
+
* step that lifted nothing. It lifts it now, so the wording went back — in the
|
|
35
|
+
* thrown message and in `errors.MFA_SOCIAL_LAST_CREDENTIAL`, both languages.
|
|
36
|
+
*
|
|
37
|
+
* **The admin arm asks nobody.** An `AdminUser` cannot exist without a password
|
|
38
|
+
* somebody supplied: `AdminUserService.create` takes a required one (as does
|
|
39
|
+
* `createAdminUserRequestSchema`), and `SocialIdentityService.signInAdmin`
|
|
40
|
+
* matches an existing admin and refuses when there is none — there is no
|
|
41
|
+
* auto-create on that side to mint a random hash. So the answer is structurally
|
|
42
|
+
* `true` and a second column would record a constant.
|
|
43
|
+
* `test/unit/mfa/admin-password-is-structural.test.ts` is what keeps that
|
|
44
|
+
* premise from rotting quietly.
|
|
45
|
+
*/
|
|
46
|
+
export declare class SocialLinkService {
|
|
47
|
+
#private;
|
|
48
|
+
private readonly emFactory;
|
|
49
|
+
private readonly commandBus;
|
|
50
|
+
private readonly passwordState;
|
|
51
|
+
constructor(emFactory: () => EntityManager, commandBus: CommandBus, passwordState: CustomerPasswordStatePort);
|
|
52
|
+
/** The links this subject holds, in link order, each with its unlink verdict. */
|
|
53
|
+
list(subject: MfaSubjectRef): Promise<MfaSocialLinkSummary[]>;
|
|
54
|
+
/**
|
|
55
|
+
* Remove one provider link from this subject.
|
|
56
|
+
*
|
|
57
|
+
* Runs through the Command Bus (Constitution XIII): a security-relevant
|
|
58
|
+
* account change is audited co-transactionally, so a rollback takes the audit
|
|
59
|
+
* row with it.
|
|
60
|
+
*/
|
|
61
|
+
unlink(subject: MfaSubjectRef, provider: MfaSocialProvider): Promise<void>;
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=social-link-service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"social-link-service.d.ts","sourceRoot":"","sources":["../../../src/backend/services/social-link-service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAE3D,OAAO,KAAK,EACV,yBAAyB,EACzB,oBAAoB,EACpB,iBAAiB,EACjB,aAAa,EACd,MAAM,4BAA4B,CAAC;AAEpC,OAAO,KAAK,EAAW,UAAU,EAAE,MAAM,oCAAoC,CAAC;AAG9E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AACH,qBAAa,iBAAiB;;IAE1B,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,UAAU;IAC3B,OAAO,CAAC,QAAQ,CAAC,aAAa;gBAFb,SAAS,EAAE,MAAM,aAAa,EAC9B,UAAU,EAAE,UAAU,EACtB,aAAa,EAAE,yBAAyB;IAG3D,iFAAiF;IAC3E,IAAI,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,oBAAoB,EAAE,CAAC;IAkBnE;;;;;;OAMG;IACG,MAAM,CAAC,OAAO,EAAE,aAAa,EAAE,QAAQ,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;CAqDjF"}
|