@camstack/server 1.2.480 → 1.2.481

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.
@@ -53,6 +53,7 @@ exports.redactStatusAggregateSetup = redactStatusAggregateSetup;
53
53
  exports.redactStatusAggregateBatchSetup = redactStatusAggregateBatchSetup;
54
54
  exports.redactConfigEntries = redactConfigEntries;
55
55
  exports.redactStreamUrlSecrets = redactStreamUrlSecrets;
56
+ exports.maskUrlCredentialsDeep = maskUrlCredentialsDeep;
56
57
  /** What a non-admin sees instead of the value. Matches the `***` convention
57
58
  * `maskUrlCredentials` already uses for credentials in log output, so one
58
59
  * redaction marker means one thing across the system. */
@@ -86,24 +87,88 @@ function isSecretConfigKey(key) {
86
87
  function isRecord(x) {
87
88
  return x !== null && typeof x === 'object' && !Array.isArray(x);
88
89
  }
90
+ function parsePath(path) {
91
+ return path
92
+ .split('.')
93
+ .filter((part) => part.length > 0)
94
+ .map((part) => part.endsWith('[]') ? { key: part.slice(0, -2), each: true } : { key: part, each: false });
95
+ }
96
+ /** The declared secret paths a device row advertises, parsed. Absent ⇒ none. */
97
+ function declaredPathsOf(row) {
98
+ const raw = row['secretConfigPaths'];
99
+ if (!Array.isArray(raw))
100
+ return [];
101
+ return raw.filter((p) => typeof p === 'string').map(parsePath);
102
+ }
103
+ /** Withhold every non-empty string at or below `value` (a declared secret node). */
104
+ function withholdAll(value) {
105
+ if (typeof value === 'string')
106
+ return value.length > 0 ? exports.REDACTED_SECRET : value;
107
+ if (Array.isArray(value))
108
+ return value.map(withholdAll);
109
+ if (!isRecord(value))
110
+ return value;
111
+ const out = {};
112
+ for (const [key, child] of Object.entries(value))
113
+ out[key] = withholdAll(child);
114
+ return out;
115
+ }
116
+ /** Apply ONE declared path to `value`, returning a copy; a path that does not resolve is a no-op. */
117
+ function withholdPath(value, segments) {
118
+ const [head, ...rest] = segments;
119
+ if (head === undefined)
120
+ return withholdAll(value);
121
+ if (!isRecord(value) || !(head.key in value))
122
+ return value;
123
+ const child = value[head.key];
124
+ const next = head.each
125
+ ? Array.isArray(child)
126
+ ? child.map((el) => withholdPath(el, rest))
127
+ : child
128
+ : withholdPath(child, rest);
129
+ return { ...value, [head.key]: next };
130
+ }
89
131
  /**
90
- * Replace the value of every credential-named key with {@link REDACTED_SECRET}.
132
+ * The keyword BACKSTOP (D634), now at any depth: a credential-NAMED key's
133
+ * string value is withheld, and a non-secret string that is a URL loses its
134
+ * userinfo / credential params. Only strings are touched — a numeric
135
+ * `maxTokens` or an array called `eventTypeTokens` is not a credential.
91
136
  *
92
- * An EMPTY value stays empty: "not configured" and "configured, withheld" are
93
- * different facts, and flattening them would make a broken camera look set up.
94
- * A non-string value is left alone — a numeric `maxTokens` is not a secret, and
95
- * the only credentials this repo persists are strings.
137
+ * This is no longer the declaration (D783): it catches what a driver has not
138
+ * yet declared, and `scripts/check-secret-fields-declared.ts` is what keeps
139
+ * that set from growing.
96
140
  */
97
- function redactRecord(config) {
141
+ function backstop(value) {
142
+ if (typeof value === 'string') {
143
+ return value.includes('://') ? (0, types_1.maskUrlCredentials)(value) : value;
144
+ }
145
+ if (Array.isArray(value))
146
+ return value.map(backstop);
147
+ if (!isRecord(value))
148
+ return value;
98
149
  const out = {};
99
- for (const [key, value] of Object.entries(config)) {
150
+ for (const [key, child] of Object.entries(value)) {
100
151
  out[key] =
101
- isSecretConfigKey(key) && typeof value === 'string' && value.length > 0
152
+ isSecretConfigKey(key) && typeof child === 'string' && child.length > 0
102
153
  ? exports.REDACTED_SECRET
103
- : value;
154
+ : backstop(child);
104
155
  }
105
156
  return out;
106
157
  }
158
+ /**
159
+ * Withhold a device `config` blob's secrets: first every DECLARED path
160
+ * (`secretConfigPaths`, at any depth), then the keyword backstop over what is
161
+ * left.
162
+ *
163
+ * An EMPTY value stays empty: "not configured" and "configured, withheld" are
164
+ * different facts, and flattening them would make a broken camera look set up.
165
+ */
166
+ function redactConfigBlob(config, declared) {
167
+ let out = config;
168
+ for (const segments of declared)
169
+ out = withholdPath(out, segments);
170
+ return backstop(out);
171
+ }
107
172
  /**
108
173
  * Project a `DeviceInfo` (or an array of them) with its `config` redacted.
109
174
  * Anything that is not a device row passes through untouched — a route that
@@ -117,7 +182,7 @@ function redactDeviceInfoSecrets(data) {
117
182
  const config = data['config'];
118
183
  if (!isRecord(config))
119
184
  return data;
120
- return { ...data, config: redactRecord(config) };
185
+ return { ...data, config: redactConfigBlob(config, declaredPathsOf(data)) };
121
186
  }
122
187
  /**
123
188
  * Project a `Record<parentId, DeviceInfo[]>` — the shape `getChildrenBatch`
@@ -349,7 +414,7 @@ function redactConfigEntries(data) {
349
414
  * `streamBroker.getRtspEntry`, `deviceOps.getStreamSources`, …) hand a
350
415
  * non-admin caller `rtsp://admin:hunter2@192.168.10.139/…` verbatim: a
351
416
  * read-only token can open every camera by copy-pasting the string. These
352
- * are NOT config blobs, so {@link redactRecord} never sees them — the leak
417
+ * are NOT config blobs, so {@link redactDeviceInfoSecrets} never sees them — the leak
353
418
  * lives in a plain `url` / `mutedUrl` string field.
354
419
  */
355
420
  const URL_FIELD_KEYS = ['url', 'mutedUrl'];
@@ -377,6 +442,27 @@ function redactStreamUrlSecrets(data) {
377
442
  }
378
443
  return out;
379
444
  }
445
+ /**
446
+ * Mask URL credentials in EVERY string of an arbitrary payload — the runtime
447
+ * state reads (`deviceState.getSnapshot` / `getCapSlice` / `getAllSnapshots`),
448
+ * the status aggregates and `streamCatalog.getCatalog`. Reolink persists its
449
+ * stream catalog (`rtsp://user:pass@…`, FLV `…&password=…`) in the
450
+ * `stream-catalog` slice, so a view-level read handed the camera password to
451
+ * any non-admin (D783). Only strings holding `://` are touched; no key is
452
+ * judged by its name here, so `eventTypeTokens` & co. keep their values.
453
+ */
454
+ function maskUrlCredentialsDeep(data) {
455
+ if (typeof data === 'string')
456
+ return data.includes('://') ? (0, types_1.maskUrlCredentials)(data) : data;
457
+ if (Array.isArray(data))
458
+ return data.map(maskUrlCredentialsDeep);
459
+ if (!isRecord(data))
460
+ return data;
461
+ const out = {};
462
+ for (const [key, value] of Object.entries(data))
463
+ out[key] = maskUrlCredentialsDeep(value);
464
+ return out;
465
+ }
380
466
  /**
381
467
  * Every device-manager read whose response carries a device `config` blob, and
382
468
  * the projection that cuts it.
@@ -408,8 +494,14 @@ exports.NON_ADMIN_CONFIG_REDACTED_METHODS = new Map([
408
494
  ['integrations.list', redactDeepSecrets],
409
495
  ['integrations.get', redactDeepSecrets],
410
496
  ['integrations.getByAddonId', redactDeepSecrets],
411
- ['deviceManager.getDeviceStatusAggregate', redactStatusAggregateSetup],
412
- ['deviceManager.getDeviceStatusAggregateBatch', redactStatusAggregateBatchSetup],
497
+ [
498
+ 'deviceManager.getDeviceStatusAggregate',
499
+ (d) => maskUrlCredentialsDeep(redactStatusAggregateSetup(d)),
500
+ ],
501
+ [
502
+ 'deviceManager.getDeviceStatusAggregateBatch',
503
+ (d) => maskUrlCredentialsDeep(redactStatusAggregateBatchSetup(d)),
504
+ ],
413
505
  ['deviceManager.getDevice', redactDeviceInfoSecrets],
414
506
  ['deviceManager.listAll', redactDeviceInfoSecrets],
415
507
  ['deviceManager.getChildren', redactDeviceInfoSecrets],
@@ -428,4 +520,10 @@ exports.NON_ADMIN_CONFIG_REDACTED_METHODS = new Map([
428
520
  ['deviceManager.getStreamSources', redactStreamUrlSecrets],
429
521
  ['streamBroker.getRtspEntry', redactStreamUrlSecrets],
430
522
  ['streamBroker.getAllRtspEntries', redactStreamUrlSecrets],
523
+ // ── Stream catalog + runtime state (D783) — Reolink persists its catalog,
524
+ // credentials inline, in the `stream-catalog` slice. Every URL is masked.
525
+ ['streamCatalog.getCatalog', maskUrlCredentialsDeep],
526
+ ['deviceState.getSnapshot', maskUrlCredentialsDeep],
527
+ ['deviceState.getCapSlice', maskUrlCredentialsDeep],
528
+ ['deviceState.getAllSnapshots', maskUrlCredentialsDeep],
431
529
  ]);
@@ -7079,6 +7079,24 @@ function createCapRouter_pipelineAnalytics(getProvider, createRemoteProxy) {
7079
7079
  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
7080
7080
  return p.getRetrainExportUrl(methodInput);
7081
7081
  }),
7082
+ setRetrainFrameComplete: trpc_middleware_js_1.adminProcedure
7083
+ .input(types_92.pipelineAnalyticsCapability.methods.setRetrainFrameComplete.input.loose())
7084
+ .output(types_92.pipelineAnalyticsCapability.methods.setRetrainFrameComplete.output)
7085
+ .mutation(async ({ input, ctx }) => {
7086
+ const { nodeId, ...methodInput } = input;
7087
+ const p = resolveProvider('pipeline-analytics', nodeId, () => getProvider(ctx), createRemoteProxy);
7088
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
7089
+ return p.setRetrainFrameComplete(methodInput);
7090
+ }),
7091
+ writeRetrainArchive: trpc_middleware_js_1.adminProcedure
7092
+ .input(types_92.pipelineAnalyticsCapability.methods.writeRetrainArchive.input.loose())
7093
+ .output(types_92.pipelineAnalyticsCapability.methods.writeRetrainArchive.output)
7094
+ .mutation(async ({ input, ctx }) => {
7095
+ const { nodeId, ...methodInput } = input;
7096
+ const p = resolveProvider('pipeline-analytics', nodeId, () => getProvider(ctx), createRemoteProxy);
7097
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
7098
+ return p.writeRetrainArchive(methodInput);
7099
+ }),
7082
7100
  getEventMedia: trpc_middleware_js_1.protectedProcedure
7083
7101
  .input(types_92.pipelineAnalyticsCapability.methods.getEventMedia.input.loose())
7084
7102
  .output(types_92.pipelineAnalyticsCapability.methods.getEventMedia.output)
@@ -10851,6 +10869,42 @@ function createCapRouter_userManagement(getProvider, createRemoteProxy) {
10851
10869
  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
10852
10870
  return p.oauthVerifyAccessToken(methodInput);
10853
10871
  }),
10872
+ listExternalIdentities: trpc_middleware_js_1.adminProcedure
10873
+ .input(types_128.userManagementCapability.methods.listExternalIdentities.input.loose())
10874
+ .output(types_128.userManagementCapability.methods.listExternalIdentities.output)
10875
+ .query(async ({ input, ctx }) => {
10876
+ const { nodeId, ...methodInput } = input;
10877
+ const p = resolveProvider('user-management', nodeId, () => getProvider(ctx), createRemoteProxy);
10878
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
10879
+ return p.listExternalIdentities(methodInput);
10880
+ }),
10881
+ findUserByExternalIdentity: trpc_middleware_js_1.adminProcedure
10882
+ .input(types_128.userManagementCapability.methods.findUserByExternalIdentity.input.loose())
10883
+ .output(types_128.userManagementCapability.methods.findUserByExternalIdentity.output)
10884
+ .query(async ({ input, ctx }) => {
10885
+ const { nodeId, ...methodInput } = input;
10886
+ const p = resolveProvider('user-management', nodeId, () => getProvider(ctx), createRemoteProxy);
10887
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
10888
+ return p.findUserByExternalIdentity(methodInput);
10889
+ }),
10890
+ linkExternalIdentity: trpc_middleware_js_1.adminProcedure
10891
+ .input(types_128.userManagementCapability.methods.linkExternalIdentity.input.loose())
10892
+ .output(types_128.userManagementCapability.methods.linkExternalIdentity.output)
10893
+ .mutation(async ({ input, ctx }) => {
10894
+ const { nodeId, ...methodInput } = input;
10895
+ const p = resolveProvider('user-management', nodeId, () => getProvider(ctx), createRemoteProxy);
10896
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
10897
+ return p.linkExternalIdentity(methodInput);
10898
+ }),
10899
+ unlinkExternalIdentity: trpc_middleware_js_1.adminProcedure
10900
+ .input(types_128.userManagementCapability.methods.unlinkExternalIdentity.input.loose())
10901
+ .output(types_128.userManagementCapability.methods.unlinkExternalIdentity.output)
10902
+ .mutation(async ({ input, ctx }) => {
10903
+ const { nodeId, ...methodInput } = input;
10904
+ const p = resolveProvider('user-management', nodeId, () => getProvider(ctx), createRemoteProxy);
10905
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
10906
+ return p.unlinkExternalIdentity(methodInput);
10907
+ }),
10854
10908
  listOauthSessions: trpc_middleware_js_1.adminProcedure
10855
10909
  .input(zod_1.z.object({ nodeId: zod_1.z.string().optional() }).optional())
10856
10910
  .output(types_128.userManagementCapability.methods.listOauthSessions.output)
@@ -269,7 +269,7 @@ function buildCapabilityRouters(services) {
269
269
  // clusterNodes — fixed core API. Write-side purge for the durable
270
270
  // offline-node history (Track A "Forget node"); read side is push-only.
271
271
  clusterNodes: (0, cluster_nodes_router_js_1.createClusterNodesRouter)(services.agentRegistry),
272
- auth: (0, auth_router_js_1.createAuthRouter)(services.authService, services.capabilityRegistry, services.moleculer, services.shareTokenService, services.handoffCodeService),
272
+ auth: (0, auth_router_js_1.createAuthRouter)(services.authService, services.capabilityRegistry, services.moleculer, services.shareTokenService, services.handoffCodeService, services.externalLinkIntents),
273
273
  // NOT MOUNTED — `mount: { kind: 'skip' }` legacy provider shapes
274
274
  // (positional args / sync returns) that don't match the codegen
275
275
  // routers' {input}-object + Promise<T> contract. The runtime builder
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AUTH_GOVERNING_CAPS = void 0;
4
+ exports.isAuthAddonDisabled = isAuthAddonDisabled;
5
+ exports.bridgeProviderAddonId = bridgeProviderAddonId;
6
+ /** Capabilities whose per-provider toggle is an auth addon's enable. */
7
+ exports.AUTH_GOVERNING_CAPS = ['auth-provider', 'user-passkeys'];
8
+ /** Whether the operator disabled `addonId` on Identity → Authentication. */
9
+ function isAuthAddonDisabled(registry, addonId) {
10
+ return exports.AUTH_GOVERNING_CAPS.some((cap) => registry.isProviderDisabled(cap, addonId));
11
+ }
12
+ /**
13
+ * The addon behind an SSO bridge's `provider` claim: `auth-oidc/<instance>`
14
+ * is signed by `auth-oidc`, anything else names its addon directly.
15
+ */
16
+ function bridgeProviderAddonId(provider) {
17
+ const slash = provider.indexOf('/');
18
+ return slash === -1 ? provider : provider.slice(0, slash);
19
+ }
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ExternalLinkIntents = void 0;
4
+ /**
5
+ * Pending "link this sign-in account" requests (D782).
6
+ *
7
+ * A signed-in user presses "Link Authentik": `auth.beginExternalLink` mints
8
+ * an intent for THAT session's user and returns the provider's start URL with
9
+ * the intent's opaque handle as `?link=`. The handle rides the IdP round trip
10
+ * (the OIDC addon keeps it in its server-side pending state, keyed by the
11
+ * OAuth `state`) and comes back inside the signed bridge. The SSO finish
12
+ * resolves it here — once — to the user it was minted for. The user id never
13
+ * travels through the browser.
14
+ *
15
+ * Hub-local and in memory on purpose: an intent lives minutes, and a hub
16
+ * restart mid-link costs the user one more click.
17
+ */
18
+ const node_crypto_1 = require("node:crypto");
19
+ /** Long enough for an IdP login with a password manager; short enough to be useless later. */
20
+ const DEFAULT_TTL_MS = 10 * 60_000;
21
+ /** Bounded: a session minting intents in a loop cannot grow this without limit. */
22
+ const DEFAULT_MAX_ENTRIES = 1_000;
23
+ class ExternalLinkIntents {
24
+ intents = new Map();
25
+ now;
26
+ ttlMs;
27
+ maxEntries;
28
+ constructor(opts = {}) {
29
+ this.now = opts.now ?? Date.now;
30
+ this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
31
+ this.maxEntries = opts.maxEntries ?? DEFAULT_MAX_ENTRIES;
32
+ }
33
+ create(userId, provider) {
34
+ this.prune();
35
+ while (this.intents.size >= this.maxEntries) {
36
+ const oldest = this.intents.keys().next();
37
+ if (oldest.done)
38
+ break;
39
+ this.intents.delete(oldest.value);
40
+ }
41
+ const handle = `li_${(0, node_crypto_1.randomBytes)(24).toString('base64url')}`;
42
+ this.intents.set(handle, { userId, provider, expiresAt: this.now() + this.ttlMs });
43
+ return handle;
44
+ }
45
+ /** The intent behind `handle`, removing it — or `null` (unknown, used, expired). */
46
+ consume(handle) {
47
+ const stored = this.intents.get(handle);
48
+ if (stored === undefined)
49
+ return null;
50
+ this.intents.delete(handle);
51
+ if (stored.expiresAt < this.now())
52
+ return null;
53
+ return { userId: stored.userId, provider: stored.provider };
54
+ }
55
+ prune() {
56
+ const now = this.now();
57
+ for (const [handle, intent] of this.intents) {
58
+ if (intent.expiresAt < now)
59
+ this.intents.delete(handle);
60
+ }
61
+ }
62
+ }
63
+ exports.ExternalLinkIntents = ExternalLinkIntents;
@@ -0,0 +1,96 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SetSecondFactorPreferenceSchema = exports.SecondFactorPreferenceSchema = exports.RemovePasskeySchema = exports.FinishDiscoverableAuthenticationSchema = exports.FinishAuthenticationSchema = exports.FinishRegistrationSchema = exports.OptionsJsonSchema = void 0;
4
+ exports.resolvePasskeyProvider = resolvePasskeyProvider;
5
+ exports.userHasPasskey = userHasPasskey;
6
+ exports.passkeySecondFactorOptedIn = passkeySecondFactorOptedIn;
7
+ const types_1 = require("@camstack/types");
8
+ const zod_1 = require("zod");
9
+ // ── Passkey (user-passkeys cap) resolution ───────────────────────────
10
+ //
11
+ // Cap-agnostic: the passkey provider is resolved through the
12
+ // `user-passkeys` CAPABILITY, never by addon id. Any addon registering
13
+ // the cap works. Resolution mirrors `hwaccel.router`:
14
+ // 1. hub-in-process collection provider (a future hub-resident impl),
15
+ // 2. else the hub-local forked child over UDS via the Moleculer cap
16
+ // proxy (`createCapabilityProxy(cap, 'hub')`) — the same routing the
17
+ // generated cap routers use.
18
+ // The proxy is loosely typed (`Record<method, (params)=>Promise<unknown>>`);
19
+ // its outputs are narrowed through the cap's own Zod schemas rather than
20
+ // cast to the provider type (repo rule: no `as` on cross-boundary values).
21
+ exports.OptionsJsonSchema = zod_1.z.object({ optionsJSON: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()) });
22
+ exports.FinishRegistrationSchema = zod_1.z.object({
23
+ success: zod_1.z.literal(true),
24
+ credentialId: zod_1.z.string(),
25
+ });
26
+ exports.FinishAuthenticationSchema = zod_1.z.object({ verified: zod_1.z.boolean() });
27
+ exports.FinishDiscoverableAuthenticationSchema = zod_1.z.object({
28
+ verified: zod_1.z.boolean(),
29
+ userId: zod_1.z.string().nullable(),
30
+ });
31
+ exports.RemovePasskeySchema = zod_1.z.object({ success: zod_1.z.literal(true) });
32
+ exports.SecondFactorPreferenceSchema = zod_1.z.object({ enabled: zod_1.z.boolean() });
33
+ exports.SetSecondFactorPreferenceSchema = zod_1.z.object({ success: zod_1.z.literal(true) });
34
+ function proxyPasskeyProvider(proxy) {
35
+ const call = async (method, params) => {
36
+ const fn = proxy[method];
37
+ if (typeof fn !== 'function')
38
+ throw new Error(`user-passkeys proxy missing method '${method}'`);
39
+ return fn(params);
40
+ };
41
+ return {
42
+ beginRegistration: async (input) => exports.OptionsJsonSchema.parse(await call('beginRegistration', input)),
43
+ finishRegistration: async (input) => exports.FinishRegistrationSchema.parse(await call('finishRegistration', input)),
44
+ beginAuthentication: async (input) => exports.OptionsJsonSchema.parse(await call('beginAuthentication', input)),
45
+ finishAuthentication: async (input) => exports.FinishAuthenticationSchema.parse(await call('finishAuthentication', input)),
46
+ beginDiscoverableAuthentication: async (input) => exports.OptionsJsonSchema.parse(await call('beginDiscoverableAuthentication', input)),
47
+ finishDiscoverableAuthentication: async (input) => exports.FinishDiscoverableAuthenticationSchema.parse(await call('finishDiscoverableAuthentication', input)),
48
+ listPasskeys: async (input) => zod_1.z.array(types_1.PasskeySummarySchema).parse(await call('listPasskeys', input)),
49
+ removePasskey: async (input) => exports.RemovePasskeySchema.parse(await call('removePasskey', input)),
50
+ getSecondFactorPreference: async (input) => exports.SecondFactorPreferenceSchema.parse(await call('getSecondFactorPreference', input)),
51
+ setSecondFactorPreference: async (input) => exports.SetSecondFactorPreferenceSchema.parse(await call('setSecondFactorPreference', input)),
52
+ };
53
+ }
54
+ function resolvePasskeyProvider(registry, moleculer) {
55
+ const local = registry?.getCollection('user-passkeys')?.[0] ?? null;
56
+ if (local)
57
+ return local;
58
+ const proxy = moleculer?.createCapabilityProxy('user-passkeys', 'hub') ?? null;
59
+ return proxy ? proxyPasskeyProvider(proxy) : null;
60
+ }
61
+ /**
62
+ * Whether the user has at least one enrolled passkey. Never throws — a
63
+ * missing provider or a transport hiccup degrades to `false` so the
64
+ * login flow is never blocked by passkey introspection.
65
+ */
66
+ async function userHasPasskey(userId, provider) {
67
+ if (!provider)
68
+ return false;
69
+ try {
70
+ const list = await provider.listPasskeys({ userId });
71
+ return list.length > 0;
72
+ }
73
+ catch {
74
+ return false;
75
+ }
76
+ }
77
+ /**
78
+ * Whether the user OPTED IN to their passkey doubling as a mandatory
79
+ * second factor after a password login. Enrolling a passkey alone only
80
+ * enables passkey-FIRST sign-in — this flag (default OFF, stored by the
81
+ * `user-passkeys` provider) is what re-arms the 2FA gate. Never throws:
82
+ * a missing provider, a provider predating the method, or a transport
83
+ * hiccup all read as `false` so the preference lookup can never lock a
84
+ * user out of the password flow.
85
+ */
86
+ async function passkeySecondFactorOptedIn(userId, provider) {
87
+ if (!provider || typeof provider.getSecondFactorPreference !== 'function')
88
+ return false;
89
+ try {
90
+ const pref = await provider.getSecondFactorPreference({ userId });
91
+ return pref.enabled;
92
+ }
93
+ catch {
94
+ return false;
95
+ }
96
+ }
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SecondFactorSchema = void 0;
4
+ exports.collectSecondFactors = collectSecondFactors;
5
+ const zod_1 = require("zod");
6
+ const passkey_provider_js_1 = require("./passkey-provider.js");
7
+ exports.SecondFactorSchema = zod_1.z.enum(['totp', 'passkey']);
8
+ async function collectSecondFactors(deps, userId, primary) {
9
+ const totpStatus = deps.getTotpStatus ? await deps.getTotpStatus({ userId }) : { enabled: false };
10
+ const factors = [];
11
+ if (totpStatus.enabled)
12
+ factors.push('totp');
13
+ if (primary !== 'passkey') {
14
+ const provider = deps.passkeyProvider();
15
+ const enrolled = await (0, passkey_provider_js_1.userHasPasskey)(userId, provider);
16
+ if (enrolled && (await (0, passkey_provider_js_1.passkeySecondFactorOptedIn)(userId, provider))) {
17
+ factors.push('passkey');
18
+ }
19
+ }
20
+ return factors;
21
+ }
@@ -52,13 +52,5 @@ function createSessionMinter(deps) {
52
52
  findUserOrNull,
53
53
  mintForRecord,
54
54
  mintForUserId: async (userId) => mintForRecord(await findUser(userId)),
55
- mintForFederatedIdentity: (identity) => mintForRecord({
56
- id: identity.userId,
57
- username: identity.username,
58
- isAdmin: identity.isAdmin,
59
- allowedProviders: '*',
60
- allowedDevices: {},
61
- scopes: [],
62
- }),
63
55
  };
64
56
  }
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.sessionUserIdFromCookie = sessionUserIdFromCookie;
4
+ exports.createSsoFinishDeps = createSsoFinishDeps;
5
+ const auth_provider_gate_js_1 = require("./auth-provider-gate.js");
6
+ const passkey_provider_js_1 = require("./passkey-provider.js");
7
+ const second_factors_js_1 = require("./second-factors.js");
8
+ const session_cookie_js_1 = require("./session-cookie.js");
9
+ /**
10
+ * The user a REAL session cookie names, or `null`. Same rule as
11
+ * `auth.exchangeSession`: a bridge or challenge token verifies under the same
12
+ * secret but carries a `kind`, and a session always has a boolean `isAdmin`
13
+ * and a string `userId`.
14
+ */
15
+ function sessionUserIdFromCookie(auth, cookieHeader) {
16
+ const token = (0, session_cookie_js_1.readSessionCookieFromHeader)(cookieHeader);
17
+ if (token === null)
18
+ return null;
19
+ try {
20
+ const payload = auth.verifyToken(token);
21
+ if (Reflect.get(payload, 'kind') !== undefined)
22
+ return null;
23
+ if (typeof payload.isAdmin !== 'boolean' || typeof payload.userId !== 'string')
24
+ return null;
25
+ return payload.userId;
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ function createSsoFinishDeps(wiring, cookieHeader) {
32
+ const { auth, registry, moleculer } = wiring;
33
+ const userManagement = () => {
34
+ const um = registry.getSingleton('user-management');
35
+ if (!um)
36
+ throw new Error('Login unavailable — `user-management` capability not registered');
37
+ return um;
38
+ };
39
+ return {
40
+ verifyBridge: (token) => auth.verifySsoBridgeToken(token),
41
+ minter: wiring.minter,
42
+ isProviderDisabled: (addonId) => (0, auth_provider_gate_js_1.isAuthAddonDisabled)(registry, addonId),
43
+ identities: {
44
+ findUser: async (provider, subject) => userManagement().findUserByExternalIdentity({ provider, subject }),
45
+ link: async (input) => {
46
+ const um = userManagement();
47
+ const holder = await um.findUserByExternalIdentity({
48
+ provider: input.provider,
49
+ subject: input.subject,
50
+ });
51
+ if (holder !== null && holder.userId !== input.userId)
52
+ return 'conflict';
53
+ await um.linkExternalIdentity(input);
54
+ return 'linked';
55
+ },
56
+ },
57
+ consumeLinkIntent: (handle) => wiring.linkIntents.consume(handle),
58
+ sessionUserId: () => sessionUserIdFromCookie(auth, cookieHeader),
59
+ secondFactors: (userId) => {
60
+ const um = registry.getSingleton('user-management');
61
+ return (0, second_factors_js_1.collectSecondFactors)({
62
+ ...(um && typeof um.getTotpStatus === 'function'
63
+ ? { getTotpStatus: (input) => um.getTotpStatus(input) }
64
+ : {}),
65
+ passkeyProvider: () => (0, passkey_provider_js_1.resolvePasskeyProvider)(registry, moleculer),
66
+ }, userId, 'sso');
67
+ },
68
+ signChallenge: (user) => auth.signTotpChallengeToken(user),
69
+ ...(wiring.logFailure !== undefined ? { logFailure: wiring.logFailure } : {}),
70
+ };
71
+ }