@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.
- package/dist/api/core/auth.router.js +188 -192
- package/dist/api/trpc/device-config-secret-redaction.js +111 -13
- package/dist/api/trpc/generated-cap-routers.js +54 -0
- package/dist/api/trpc/trpc.router.js +1 -1
- package/dist/auth/auth-provider-gate.js +19 -0
- package/dist/auth/external-link-intents.js +63 -0
- package/dist/auth/passkey-provider.js +96 -0
- package/dist/auth/second-factors.js +21 -0
- package/dist/auth/session-mint.js +0 -8
- package/dist/auth/sso-finish-deps.js +71 -0
- package/dist/auth/sso-finish.js +124 -38
- package/dist/main.js +16 -3
- package/package.json +8 -8
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
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
|
|
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,
|
|
150
|
+
for (const [key, child] of Object.entries(value)) {
|
|
100
151
|
out[key] =
|
|
101
|
-
isSecretConfigKey(key) && typeof
|
|
152
|
+
isSecretConfigKey(key) && typeof child === 'string' && child.length > 0
|
|
102
153
|
? exports.REDACTED_SECRET
|
|
103
|
-
:
|
|
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:
|
|
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
|
|
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
|
-
[
|
|
412
|
-
|
|
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
|
+
}
|