@camstack/server 1.2.486 → 1.2.487
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/agent/agent-mesh-join.js +80 -0
- package/dist/agent/main.js +61 -2
- package/dist/api/auth-whoami.js +1 -1
- package/dist/api/core/auth-admin-tokens.procedures.js +138 -0
- package/dist/api/core/auth-limits.js +77 -0
- package/dist/api/core/auth-reauth.procedures.js +108 -0
- package/dist/api/core/auth-sessions.procedures.js +166 -0
- package/dist/api/core/auth.router.js +281 -80
- package/dist/api/health/adaptive-probe.routes.js +1 -1
- package/dist/api/trpc/core-cap-bridge.js +47 -5
- package/dist/api/trpc/generated-cap-mounts.js +19 -1
- package/dist/api/trpc/generated-cap-routers.js +512 -205
- package/dist/api/trpc/trpc.context.js +86 -34
- package/dist/api/trpc/trpc.middleware.js +8 -11
- package/dist/api/trpc/trpc.router.js +6 -1
- package/dist/auth/auth-network-policy.js +75 -0
- package/dist/auth/auth-rate-limit.js +25 -20
- package/dist/auth/client-address.js +68 -0
- package/dist/auth/external-link-intents.js +12 -3
- package/dist/auth/hub-sessions.js +74 -0
- package/dist/auth/native-link-completions.js +68 -0
- package/dist/auth/native-return.js +33 -0
- package/dist/auth/session-lifetimes.js +46 -0
- package/dist/auth/session-mint.js +110 -13
- package/dist/auth/sso-finish-deps.js +56 -1
- package/dist/auth/sso-finish.js +132 -17
- package/dist/auth/sso-routes.js +95 -0
- package/dist/auth/used-bridge-ids.js +52 -0
- package/dist/core/addon/addon-registry.service.js +5 -0
- package/dist/core/agent/agent-registry.service.js +10 -0
- package/dist/core/auth/auth.service.js +50 -1
- package/dist/core/auth/handoff-code.service.js +9 -3
- package/dist/core/moleculer/hub-mesh-guard.js +132 -0
- package/dist/core/moleculer/moleculer.service.js +33 -0
- package/dist/main.js +92 -28
- package/package.json +13 -13
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ssoReturnLocation = ssoReturnLocation;
|
|
4
|
+
exports.browserHandoffLocation = browserHandoffLocation;
|
|
5
|
+
/**
|
|
6
|
+
* Where the hub's auth routes send a browser, as `Location` values (D801).
|
|
7
|
+
*
|
|
8
|
+
* Pure, so the two rules that live in `main.ts` routes are specced here:
|
|
9
|
+
* - an SSO return is an admin-ui path relative to the hub's mount — EXCEPT a
|
|
10
|
+
* native app return (`camstack://auth-callback?…`), which is not a hub path
|
|
11
|
+
* and must never be prefixed;
|
|
12
|
+
* - a browser handoff lands on the account page and nowhere else.
|
|
13
|
+
*/
|
|
14
|
+
const types_1 = require("@camstack/types");
|
|
15
|
+
const sso_finish_js_1 = require("./sso-finish.js");
|
|
16
|
+
function ssoReturnLocation(mount, location) {
|
|
17
|
+
return (0, types_1.hasUrlScheme)(location) ? location : `${mount}${location}`;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The account page, signed in by a session in the FRAGMENT that admin-ui's
|
|
21
|
+
* auth boot consumes (`#token=…&next=…`, as an SSO login return). Without a
|
|
22
|
+
* session — unknown, used or expired code — the plain login page.
|
|
23
|
+
*/
|
|
24
|
+
function browserHandoffLocation(mount, session) {
|
|
25
|
+
if (session === null)
|
|
26
|
+
return `${mount}${sso_finish_js_1.SSO_LOGIN_PATH}`;
|
|
27
|
+
const fragment = new URLSearchParams({
|
|
28
|
+
...(0, sso_finish_js_1.sessionFragmentParams)(session),
|
|
29
|
+
next: sso_finish_js_1.SSO_PROFILE_PATH,
|
|
30
|
+
provider: 'browser-handoff',
|
|
31
|
+
});
|
|
32
|
+
return `${mount}${sso_finish_js_1.SSO_LOGIN_PATH}#${fragment.toString()}`;
|
|
33
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DEFAULT_ACCESS_TOKEN_EXPIRY = exports.SESSION_LIFETIME_KEY = exports.ACCESS_TOKEN_EXPIRY_KEY = void 0;
|
|
4
|
+
exports.readSessionLifetimes = readSessionLifetimes;
|
|
5
|
+
/**
|
|
6
|
+
* The two lifetimes of a login (System → Authentication):
|
|
7
|
+
*
|
|
8
|
+
* - `auth.tokenExpiry` — how long a SESSION lives without being used
|
|
9
|
+
* (sliding: every refresh or renewal moves it), default 30 days;
|
|
10
|
+
* - `auth.accessTokenExpiry` — how long one ACCESS token lives before a
|
|
11
|
+
* refreshing client swaps it, default 2 hours, bounded to [5 min, 24 h]
|
|
12
|
+
* and never past the session.
|
|
13
|
+
*
|
|
14
|
+
* Read per mint (the hub's ConfigService answers from memory), so a change
|
|
15
|
+
* applies to the next login or refresh. A value that does not parse falls
|
|
16
|
+
* back to the default — never to something longer.
|
|
17
|
+
*/
|
|
18
|
+
const system_1 = require("@camstack/system");
|
|
19
|
+
exports.ACCESS_TOKEN_EXPIRY_KEY = 'auth.accessTokenExpiry';
|
|
20
|
+
exports.SESSION_LIFETIME_KEY = 'auth.tokenExpiry';
|
|
21
|
+
exports.DEFAULT_ACCESS_TOKEN_EXPIRY = '2h';
|
|
22
|
+
const DEFAULT_ACCESS_SEC = 2 * 3600;
|
|
23
|
+
const MIN_ACCESS_SEC = 5 * 60;
|
|
24
|
+
const MAX_ACCESS_SEC = 24 * 3600;
|
|
25
|
+
function safeRead(read, key) {
|
|
26
|
+
try {
|
|
27
|
+
return read(key);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function readSessionLifetimes(read) {
|
|
34
|
+
const session = (0, system_1.parseTokenExpiry)(safeRead(read, exports.SESSION_LIFETIME_KEY));
|
|
35
|
+
const configuredAccess = safeRead(read, exports.ACCESS_TOKEN_EXPIRY_KEY);
|
|
36
|
+
const access = configuredAccess === undefined || configuredAccess === null || configuredAccess === ''
|
|
37
|
+
? null
|
|
38
|
+
: (0, system_1.parseTokenExpiry)(configuredAccess);
|
|
39
|
+
const accessSec = access !== null && access.ok
|
|
40
|
+
? Math.min(MAX_ACCESS_SEC, Math.max(MIN_ACCESS_SEC, access.seconds))
|
|
41
|
+
: DEFAULT_ACCESS_SEC;
|
|
42
|
+
return {
|
|
43
|
+
sessionMs: session.seconds * 1000,
|
|
44
|
+
accessSec: Math.min(accessSec, session.seconds),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.SessionDirectoryUnavailableError = exports.SessionUserGoneError = void 0;
|
|
3
|
+
exports.SessionRefreshRefusedError = exports.SessionDirectoryUnavailableError = exports.SessionUserGoneError = void 0;
|
|
4
4
|
exports.createSessionMinter = createSessionMinter;
|
|
5
5
|
/** The user a credential names is not in `user-management` (deleted, or never existed). */
|
|
6
6
|
class SessionUserGoneError extends Error {
|
|
@@ -20,6 +20,16 @@ class SessionDirectoryUnavailableError extends Error {
|
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
22
|
exports.SessionDirectoryUnavailableError = SessionDirectoryUnavailableError;
|
|
23
|
+
/** A refresh token that does not continue a live session. */
|
|
24
|
+
class SessionRefreshRefusedError extends Error {
|
|
25
|
+
reason;
|
|
26
|
+
constructor(reason) {
|
|
27
|
+
super('Session expired — sign in again');
|
|
28
|
+
this.reason = reason;
|
|
29
|
+
this.name = 'SessionRefreshRefusedError';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
exports.SessionRefreshRefusedError = SessionRefreshRefusedError;
|
|
23
33
|
function createSessionMinter(deps) {
|
|
24
34
|
const findUserOrNull = async (userId) => {
|
|
25
35
|
const directory = deps.directory();
|
|
@@ -35,22 +45,109 @@ function createSessionMinter(deps) {
|
|
|
35
45
|
throw new SessionUserGoneError(userId);
|
|
36
46
|
return record;
|
|
37
47
|
};
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
const backing = () => {
|
|
49
|
+
if (deps.sessions === undefined)
|
|
50
|
+
return null;
|
|
51
|
+
const live = deps.sessions();
|
|
52
|
+
if (live === null)
|
|
53
|
+
throw new SessionDirectoryUnavailableError();
|
|
54
|
+
return live;
|
|
55
|
+
};
|
|
56
|
+
const claims = (record, sid) => ({
|
|
57
|
+
...(sid !== undefined ? { sid } : {}),
|
|
58
|
+
userId: record.id,
|
|
59
|
+
username: record.username,
|
|
60
|
+
isAdmin: record.isAdmin,
|
|
61
|
+
allowedProviders: record.allowedProviders ?? '*',
|
|
62
|
+
allowedDevices: record.allowedDevices ?? {},
|
|
63
|
+
scopes: record.scopes ?? [],
|
|
64
|
+
});
|
|
65
|
+
const user = (record) => ({
|
|
66
|
+
id: record.id,
|
|
67
|
+
username: record.username,
|
|
68
|
+
isAdmin: record.isAdmin,
|
|
49
69
|
});
|
|
70
|
+
/** Sign an access token for `session`: short for a refreshing client, the session's own life otherwise. */
|
|
71
|
+
const signFor = (record, session, refreshable, live) => {
|
|
72
|
+
const remainingSec = Math.max(60, Math.floor((session.expiresAt - Date.now()) / 1000));
|
|
73
|
+
const expiresInSec = refreshable ? Math.min(live.accessSec(), remainingSec) : remainingSec;
|
|
74
|
+
return {
|
|
75
|
+
token: deps.signer.signToken(claims(record, session.id), { expiresInSec }),
|
|
76
|
+
expiresAt: Date.now() + expiresInSec * 1000,
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
const mintForRecord = async (record, issue) => {
|
|
80
|
+
const live = backing();
|
|
81
|
+
if (live === null) {
|
|
82
|
+
return {
|
|
83
|
+
token: deps.signer.signToken(claims(record, undefined)),
|
|
84
|
+
user: user(record),
|
|
85
|
+
requiresTotp: false,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
const refreshable = issue.refreshable === true;
|
|
89
|
+
const opened = await live.open({
|
|
90
|
+
userId: record.id,
|
|
91
|
+
refreshable,
|
|
92
|
+
method: issue.method,
|
|
93
|
+
...(issue.client !== undefined ? { client: issue.client } : {}),
|
|
94
|
+
...(issue.ip !== undefined ? { ip: issue.ip } : {}),
|
|
95
|
+
});
|
|
96
|
+
const access = signFor(record, opened.session, refreshable, live);
|
|
97
|
+
return {
|
|
98
|
+
token: access.token,
|
|
99
|
+
user: user(record),
|
|
100
|
+
requiresTotp: false,
|
|
101
|
+
expiresAt: access.expiresAt,
|
|
102
|
+
sessionId: opened.session.id,
|
|
103
|
+
sessionExpiresAt: opened.session.expiresAt,
|
|
104
|
+
...(opened.refreshToken !== null ? { refreshToken: opened.refreshToken } : {}),
|
|
105
|
+
};
|
|
106
|
+
};
|
|
50
107
|
return {
|
|
51
108
|
findUser,
|
|
52
109
|
findUserOrNull,
|
|
53
110
|
mintForRecord,
|
|
54
|
-
mintForUserId: async (userId) => mintForRecord(await findUser(userId)),
|
|
111
|
+
mintForUserId: async (userId, issue) => mintForRecord(await findUser(userId), issue),
|
|
112
|
+
continueSession: async (userId, sessionId) => {
|
|
113
|
+
const live = backing();
|
|
114
|
+
const record = await findUser(userId);
|
|
115
|
+
if (live === null)
|
|
116
|
+
return mintForRecord(record, { method: 'handoff' });
|
|
117
|
+
const session = await live.extend(sessionId);
|
|
118
|
+
if (session === null || session.userId !== userId) {
|
|
119
|
+
throw new SessionRefreshRefusedError('revoked');
|
|
120
|
+
}
|
|
121
|
+
const access = signFor(record, session, false, live);
|
|
122
|
+
return {
|
|
123
|
+
token: access.token,
|
|
124
|
+
user: user(record),
|
|
125
|
+
requiresTotp: false,
|
|
126
|
+
expiresAt: access.expiresAt,
|
|
127
|
+
sessionId: session.id,
|
|
128
|
+
sessionExpiresAt: session.expiresAt,
|
|
129
|
+
};
|
|
130
|
+
},
|
|
131
|
+
refresh: async (refreshToken) => {
|
|
132
|
+
const live = backing();
|
|
133
|
+
if (live === null)
|
|
134
|
+
throw new SessionRefreshRefusedError('unknown');
|
|
135
|
+
const rotated = await live.rotate(refreshToken);
|
|
136
|
+
if (!rotated.ok)
|
|
137
|
+
throw new SessionRefreshRefusedError(rotated.reason);
|
|
138
|
+
const record = await findUserOrNull(rotated.session.userId);
|
|
139
|
+
if (record === null)
|
|
140
|
+
throw new SessionRefreshRefusedError('user-gone');
|
|
141
|
+
const access = signFor(record, rotated.session, true, live);
|
|
142
|
+
return {
|
|
143
|
+
token: access.token,
|
|
144
|
+
user: user(record),
|
|
145
|
+
requiresTotp: false,
|
|
146
|
+
expiresAt: access.expiresAt,
|
|
147
|
+
sessionId: rotated.session.id,
|
|
148
|
+
sessionExpiresAt: rotated.session.expiresAt,
|
|
149
|
+
refreshToken: rotated.refreshToken,
|
|
150
|
+
};
|
|
151
|
+
},
|
|
55
152
|
};
|
|
56
153
|
}
|
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.sessionUserIdFromCookie = sessionUserIdFromCookie;
|
|
4
|
+
exports.readCookie = readCookie;
|
|
5
|
+
exports.ssoBrowserBinding = ssoBrowserBinding;
|
|
4
6
|
exports.createSsoFinishDeps = createSsoFinishDeps;
|
|
7
|
+
/**
|
|
8
|
+
* The live wiring of `finishSsoLogin`'s deps (D777, D782) — kept out of
|
|
9
|
+
* `main.ts` so the route there stays a three-line mapping.
|
|
10
|
+
*/
|
|
11
|
+
const node_crypto_1 = require("node:crypto");
|
|
12
|
+
const system_1 = require("@camstack/system");
|
|
13
|
+
const types_1 = require("@camstack/types");
|
|
5
14
|
const auth_provider_gate_js_1 = require("./auth-provider-gate.js");
|
|
6
15
|
const passkey_provider_js_1 = require("./passkey-provider.js");
|
|
7
16
|
const second_factors_js_1 = require("./second-factors.js");
|
|
@@ -28,6 +37,26 @@ function sessionUserIdFromCookie(auth, cookieHeader) {
|
|
|
28
37
|
return null;
|
|
29
38
|
}
|
|
30
39
|
}
|
|
40
|
+
/** The value of one cookie in a raw `Cookie` header, or `null`. */
|
|
41
|
+
function readCookie(header, name) {
|
|
42
|
+
if (header === undefined)
|
|
43
|
+
return null;
|
|
44
|
+
for (const pair of header.split(';')) {
|
|
45
|
+
const eq = pair.indexOf('=');
|
|
46
|
+
if (eq === -1)
|
|
47
|
+
continue;
|
|
48
|
+
if (pair.slice(0, eq).trim() !== name)
|
|
49
|
+
continue;
|
|
50
|
+
const value = pair.slice(eq + 1).trim();
|
|
51
|
+
return value.length > 0 ? value : null;
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
/** SHA-256 (hex) of the request's SSO state cookie, or `null` without one. */
|
|
56
|
+
function ssoBrowserBinding(cookieHeader) {
|
|
57
|
+
const value = readCookie(cookieHeader, types_1.SSO_STATE_COOKIE);
|
|
58
|
+
return value === null ? null : (0, node_crypto_1.createHash)('sha256').update(value).digest('hex');
|
|
59
|
+
}
|
|
31
60
|
function createSsoFinishDeps(wiring, cookieHeader) {
|
|
32
61
|
const { auth, registry, moleculer } = wiring;
|
|
33
62
|
const userManagement = () => {
|
|
@@ -50,11 +79,37 @@ function createSsoFinishDeps(wiring, cookieHeader) {
|
|
|
50
79
|
});
|
|
51
80
|
if (holder !== null && holder.userId !== input.userId)
|
|
52
81
|
return 'conflict';
|
|
53
|
-
|
|
82
|
+
// Hub-internal: creating a link is on no wire (not tRPC, not the
|
|
83
|
+
// mesh, not ctx.api) — only this in-process caller reaches it.
|
|
84
|
+
const internal = (0, system_1.getUserManagementInternal)(um);
|
|
85
|
+
if (internal === null)
|
|
86
|
+
throw new Error('External sign-in linking not available');
|
|
87
|
+
await internal.linkExternalIdentity(input);
|
|
54
88
|
return 'linked';
|
|
55
89
|
},
|
|
56
90
|
},
|
|
57
91
|
consumeLinkIntent: (handle) => wiring.linkIntents.consume(handle),
|
|
92
|
+
parkNativeLink: (link) => wiring.nativeLinks.park(link),
|
|
93
|
+
browserBinding: () => ssoBrowserBinding(cookieHeader),
|
|
94
|
+
consumeBridgeId: (jti) => wiring.usedBridgeIds.consume(jti),
|
|
95
|
+
...(wiring.sessionIssue !== undefined ? { sessionIssue: wiring.sessionIssue } : {}),
|
|
96
|
+
...(wiring.trustedNetworkLogin !== undefined
|
|
97
|
+
? {
|
|
98
|
+
trustedNetworkLogin: wiring.trustedNetworkLogin,
|
|
99
|
+
auditSkip: async (userId, factors) => {
|
|
100
|
+
const internal = (0, system_1.getUserManagementInternal)(registry.getSingleton('user-management'));
|
|
101
|
+
if (internal === null)
|
|
102
|
+
throw new Error('auth audit unavailable');
|
|
103
|
+
const ip = wiring.sessionIssue?.().ip;
|
|
104
|
+
await internal.audit.record({
|
|
105
|
+
action: 'second-factor-skipped',
|
|
106
|
+
userId,
|
|
107
|
+
...(ip !== undefined ? { ip } : {}),
|
|
108
|
+
detail: { method: 'sso', factors: factors.join(',') },
|
|
109
|
+
});
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
: {}),
|
|
58
113
|
sessionUserId: () => sessionUserIdFromCookie(auth, cookieHeader),
|
|
59
114
|
secondFactors: (userId) => {
|
|
60
115
|
const um = registry.getSingleton('user-management');
|
package/dist/auth/sso-finish.js
CHANGED
|
@@ -1,13 +1,52 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.SSO_POPUP_COMPLETE_PATH = exports.SSO_LOGIN_PATH = exports.SSO_PROFILE_PATH = exports.OIDC_IDENTITY_PREFIX = exports.OIDC_BRIDGE_PROVIDER_PREFIX =
|
|
3
|
+
exports.SSO_POPUP_COMPLETE_PATH = exports.SSO_LOGIN_PATH = exports.SSO_PROFILE_PATH = exports.OIDC_IDENTITY_PREFIX = exports.OIDC_BRIDGE_PROVIDER_PREFIX = void 0;
|
|
4
4
|
exports.popupCompletionLocation = popupCompletionLocation;
|
|
5
5
|
exports.isLoginBridge = isLoginBridge;
|
|
6
|
+
exports.appCallbackLocation = appCallbackLocation;
|
|
7
|
+
exports.sessionFragmentParams = sessionFragmentParams;
|
|
6
8
|
exports.finishSsoLogin = finishSsoLogin;
|
|
9
|
+
/**
|
|
10
|
+
* `/api/auth/sso/finish` — swaps a verified login bridge for a session (D712),
|
|
11
|
+
* or completes an explicit OIDC link (D782).
|
|
12
|
+
*
|
|
13
|
+
* An auth-provider addon (OIDC) mints an HMAC-signed bridge
|
|
14
|
+
* through the `sso-bridge` cap and 302s here with `?bridge=<jwt>`. The bridge
|
|
15
|
+
* proves WHO signed in at the IdP; it never decides what the session may do:
|
|
16
|
+
*
|
|
17
|
+
* - Only a LOGIN bridge is accepted. The `sso-bridge` family also carries the
|
|
18
|
+
* OAuth account-link tokens (`oauth-code` travels in a browser redirect,
|
|
19
|
+
* `oauth-access` / `oauth-refresh` are a component's credentials) — all
|
|
20
|
+
* verify under the same secret, and each used to be exchangeable here for a
|
|
21
|
+
* full browser session of the linking user.
|
|
22
|
+
* - A bridge whose provider addon is disabled on Identity → Authentication
|
|
23
|
+
* is refused (D777): that toggle is the provider's only enable.
|
|
24
|
+
* - An OIDC bridge names an identity `oidc:<instance>:<sub>` (D782). It signs
|
|
25
|
+
* in ONLY the user that identity was explicitly linked to, from that user's
|
|
26
|
+
* record — no federated session, no auto-creation, no email matching. An
|
|
27
|
+
* unlinked identity is sent back to the login page with a message.
|
|
28
|
+
* - Every login owes the user's enrolled second factors (TOTP; an opted-in
|
|
29
|
+
* passkey): the finish then hands the login page a challenge, not a
|
|
30
|
+
* session — the same rule as a password login.
|
|
31
|
+
* - A bridge is single-use (its `jti` is recorded) and bound to the browser
|
|
32
|
+
* that started the sign-in (the `SSO_STATE_COOKIE` hash it carries must
|
|
33
|
+
* match the cookie this request presents). A bridge copied out of a URL is
|
|
34
|
+
* useless in another browser and useless a second time.
|
|
35
|
+
* - A bridge carrying a `linkIntent` is a LINK, never a login: the hub-side
|
|
36
|
+
* intent (minted for a signed-in user, single-use) names the user, and the
|
|
37
|
+
* browser completing the flow must still hold THAT user's session cookie.
|
|
38
|
+
* - A link started from the native app (`returnTo` on the intent, D801) ends
|
|
39
|
+
* in the system browser, which holds no hub session: the verified identity
|
|
40
|
+
* is PARKED under a one-time code and the browser goes back to the app's
|
|
41
|
+
* custom scheme. The app claims it with its own bearer
|
|
42
|
+
* (`auth.completeExternalLink`), where the user binding is checked.
|
|
43
|
+
*
|
|
44
|
+
* Pure: the Fastify route in `main.ts` wires the deps and maps the outcome.
|
|
45
|
+
*/
|
|
46
|
+
const node_crypto_1 = require("node:crypto");
|
|
47
|
+
const types_1 = require("@camstack/types");
|
|
7
48
|
const auth_provider_gate_js_1 = require("./auth-provider-gate.js");
|
|
8
49
|
const session_mint_js_1 = require("./session-mint.js");
|
|
9
|
-
/** The magic-link addon's bridge `provider`. */
|
|
10
|
-
exports.MAGIC_LINK_BRIDGE_PROVIDER = 'auth-magic-link';
|
|
11
50
|
/**
|
|
12
51
|
* `auth-oidc` signs `auth-oidc/<instance>`.
|
|
13
52
|
*
|
|
@@ -15,10 +54,9 @@ exports.MAGIC_LINK_BRIDGE_PROVIDER = 'auth-magic-link';
|
|
|
15
54
|
* linkable (that is the `linkable` flag on a login method, which gates the
|
|
16
55
|
* intent a link needs). It stays because it is load-bearing: it is the
|
|
17
56
|
* allowlist that keeps the `oauth-*` tokens of the same signer from being
|
|
18
|
-
* swapped for a session
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* non-OIDC linkable provider fails closed until it has its own bridge format.
|
|
57
|
+
* swapped for a session. A bridge with any other provider is refused, so a
|
|
58
|
+
* non-OIDC provider fails closed until it has its own bridge format. (The
|
|
59
|
+
* magic-link bridge that named a user record directly is gone with its addon.)
|
|
22
60
|
*/
|
|
23
61
|
exports.OIDC_BRIDGE_PROVIDER_PREFIX = 'auth-oidc/';
|
|
24
62
|
/** `auth-oidc` names an identity `oidc:<instance>:<sub>`. */
|
|
@@ -79,8 +117,7 @@ function oidcIdentityOf(claims) {
|
|
|
79
117
|
}
|
|
80
118
|
/** Whether `claims` is a LOGIN bridge — the only kind this endpoint swaps. */
|
|
81
119
|
function isLoginBridge(claims) {
|
|
82
|
-
return
|
|
83
|
-
claims.provider.startsWith(exports.OIDC_BRIDGE_PROVIDER_PREFIX));
|
|
120
|
+
return claims.provider.startsWith(exports.OIDC_BRIDGE_PROVIDER_PREFIX);
|
|
84
121
|
}
|
|
85
122
|
function unauthorized(error) {
|
|
86
123
|
return { kind: 'error', status: 401, body: { error } };
|
|
@@ -91,6 +128,10 @@ function redirect(location) {
|
|
|
91
128
|
function linkRefused(reason) {
|
|
92
129
|
return redirect(`${PROFILE_PATH}?linkError=${reason}`);
|
|
93
130
|
}
|
|
131
|
+
/** The app's callback with one query parameter — `link=<code>` or `linkError=<reason>`. */
|
|
132
|
+
function appCallbackLocation(returnTo, key, value) {
|
|
133
|
+
return `${returnTo}?${key}=${encodeURIComponent(value)}`;
|
|
134
|
+
}
|
|
94
135
|
function failed(error, deps) {
|
|
95
136
|
if (error instanceof session_mint_js_1.SessionUserGoneError)
|
|
96
137
|
return unauthorized('User no longer exists');
|
|
@@ -98,14 +139,45 @@ function failed(error, deps) {
|
|
|
98
139
|
deps.logFailure?.(error);
|
|
99
140
|
return { kind: 'error', status: 500, body: { error: 'SSO finish failed' } };
|
|
100
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* The session a URL fragment hands the admin-ui: the access token, and — the
|
|
144
|
+
* admin-ui is a refreshing client — the refresh token and both expiries. A
|
|
145
|
+
* fragment never reaches a server log or a Referer.
|
|
146
|
+
*/
|
|
147
|
+
function sessionFragmentParams(session) {
|
|
148
|
+
return {
|
|
149
|
+
token: session.token,
|
|
150
|
+
...(session.refreshToken !== undefined ? { refreshToken: session.refreshToken } : {}),
|
|
151
|
+
...(session.expiresAt !== undefined ? { expiresAt: String(session.expiresAt) } : {}),
|
|
152
|
+
...(session.sessionExpiresAt !== undefined
|
|
153
|
+
? { sessionExpiresAt: String(session.sessionExpiresAt) }
|
|
154
|
+
: {}),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/** May this login skip its factors (opt-in trusted network)? Only when the skip could be audited. */
|
|
158
|
+
async function skippedHere(userId, owed, deps) {
|
|
159
|
+
if (owed.length === 0 || deps.trustedNetworkLogin?.() !== true || deps.auditSkip === undefined) {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
await deps.auditSkip(userId, owed);
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
deps.logFailure?.(error);
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
101
171
|
/** A session for `record` — or the second-factor challenge it still owes. */
|
|
102
172
|
async function signIn(record, provider, deps) {
|
|
103
|
-
const
|
|
173
|
+
const owed = await deps.secondFactors(record.id);
|
|
174
|
+
const factors = (await skippedHere(record.id, owed, deps)) ? [] : owed;
|
|
104
175
|
if (factors.length > 0) {
|
|
105
176
|
const challenge = deps.signChallenge({
|
|
106
177
|
userId: record.id,
|
|
107
178
|
username: record.username,
|
|
108
179
|
isAdmin: record.isAdmin,
|
|
180
|
+
method: 'sso',
|
|
109
181
|
});
|
|
110
182
|
// The challenge rides the URL FRAGMENT, like the session token below: it
|
|
111
183
|
// never reaches a server log. The login page resumes at the factor step.
|
|
@@ -116,8 +188,13 @@ async function signIn(record, provider, deps) {
|
|
|
116
188
|
});
|
|
117
189
|
return redirect(`${LOGIN_PATH}#${fragment.toString()}`);
|
|
118
190
|
}
|
|
119
|
-
const session = deps.minter.mintForRecord(record
|
|
120
|
-
|
|
191
|
+
const session = await deps.minter.mintForRecord(record, {
|
|
192
|
+
method: 'sso',
|
|
193
|
+
refreshable: true,
|
|
194
|
+
...deps.sessionIssue?.(),
|
|
195
|
+
});
|
|
196
|
+
const fragment = new URLSearchParams({ ...sessionFragmentParams(session), provider });
|
|
197
|
+
return redirect(`${LOGIN_PATH}#${fragment.toString()}`);
|
|
121
198
|
}
|
|
122
199
|
async function completeLink(claims, handle, deps) {
|
|
123
200
|
const identity = oidcIdentityOf(claims);
|
|
@@ -127,6 +204,21 @@ async function completeLink(claims, handle, deps) {
|
|
|
127
204
|
const intent = deps.consumeLinkIntent(handle);
|
|
128
205
|
if (intent === null)
|
|
129
206
|
return linkRefused('expired');
|
|
207
|
+
if (intent.returnTo !== undefined) {
|
|
208
|
+
// Native (D801): no session cookie to check here — the app's claim is
|
|
209
|
+
// bound to the intent's user by `auth.completeExternalLink`.
|
|
210
|
+
if (intent.provider !== identity.provider) {
|
|
211
|
+
return redirect(appCallbackLocation(intent.returnTo, 'linkError', 'provider'));
|
|
212
|
+
}
|
|
213
|
+
const code = deps.parkNativeLink({
|
|
214
|
+
userId: intent.userId,
|
|
215
|
+
provider: identity.provider,
|
|
216
|
+
subject: identity.subject,
|
|
217
|
+
issuer: identity.issuer,
|
|
218
|
+
label: claims.email ?? claims.username,
|
|
219
|
+
});
|
|
220
|
+
return redirect(appCallbackLocation(intent.returnTo, 'link', code));
|
|
221
|
+
}
|
|
130
222
|
if (intent.provider !== identity.provider)
|
|
131
223
|
return linkRefused('provider');
|
|
132
224
|
// Bound to the session that asked: the browser finishing the IdP round trip
|
|
@@ -147,11 +239,6 @@ async function completeLink(claims, handle, deps) {
|
|
|
147
239
|
return redirect(`${PROFILE_PATH}?linked=${encodeURIComponent(identity.provider)}`);
|
|
148
240
|
}
|
|
149
241
|
async function completeLogin(claims, deps) {
|
|
150
|
-
if (claims.provider === exports.MAGIC_LINK_BRIDGE_PROVIDER) {
|
|
151
|
-
if (claims.userId.startsWith(exports.OIDC_IDENTITY_PREFIX))
|
|
152
|
-
return unauthorized('Not a user record');
|
|
153
|
-
return signIn(await deps.minter.findUser(claims.userId), claims.provider, deps);
|
|
154
|
-
}
|
|
155
242
|
const identity = oidcIdentityOf(claims);
|
|
156
243
|
if (identity === null)
|
|
157
244
|
return unauthorized('Malformed OIDC identity');
|
|
@@ -167,8 +254,31 @@ async function finishSsoLogin(bridge, deps, options = {}) {
|
|
|
167
254
|
const outcome = await finishSsoReturn(bridge, deps);
|
|
168
255
|
if (options.popup !== true || outcome.kind !== 'redirect')
|
|
169
256
|
return outcome;
|
|
257
|
+
// A native return goes to the app's scheme, never to a popup page.
|
|
258
|
+
if ((0, types_1.hasUrlScheme)(outcome.location))
|
|
259
|
+
return outcome;
|
|
170
260
|
return redirect(popupCompletionLocation(outcome.location));
|
|
171
261
|
}
|
|
262
|
+
/** Constant-time compare of two hex digests (equal length or not equal). */
|
|
263
|
+
function sameDigest(a, b) {
|
|
264
|
+
const left = Buffer.from(a, 'utf8');
|
|
265
|
+
const right = Buffer.from(b, 'utf8');
|
|
266
|
+
return left.length === right.length && (0, node_crypto_1.timingSafeEqual)(left, right);
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Is this the FIRST presentation of the bridge, in the browser that started
|
|
270
|
+
* the sign-in? The binding is checked BEFORE the jti is recorded, so a bridge
|
|
271
|
+
* presented from a browser without the cookie is refused without being burned
|
|
272
|
+
* — the real browser can still finish.
|
|
273
|
+
*/
|
|
274
|
+
function bridgeIsFirstUseHere(claims, deps) {
|
|
275
|
+
if (claims.jti === undefined || claims.browserBinding === undefined)
|
|
276
|
+
return false;
|
|
277
|
+
const presented = deps.browserBinding();
|
|
278
|
+
if (presented === null || !sameDigest(presented, claims.browserBinding))
|
|
279
|
+
return false;
|
|
280
|
+
return deps.consumeBridgeId(claims.jti);
|
|
281
|
+
}
|
|
172
282
|
async function finishSsoReturn(bridge, deps) {
|
|
173
283
|
if (!bridge)
|
|
174
284
|
return { kind: 'error', status: 400, body: { error: 'Missing bridge token' } };
|
|
@@ -180,6 +290,11 @@ async function finishSsoReturn(bridge, deps) {
|
|
|
180
290
|
if (deps.isProviderDisabled((0, auth_provider_gate_js_1.bridgeProviderAddonId)(claims.provider))) {
|
|
181
291
|
return unauthorized('Login provider is disabled');
|
|
182
292
|
}
|
|
293
|
+
if (!bridgeIsFirstUseHere(claims, deps)) {
|
|
294
|
+
return claims.linkIntent !== undefined
|
|
295
|
+
? linkRefused('state')
|
|
296
|
+
: redirect(`${LOGIN_PATH}#error=sso-state`);
|
|
297
|
+
}
|
|
183
298
|
try {
|
|
184
299
|
return claims.linkIntent !== undefined
|
|
185
300
|
? await completeLink(claims, claims.linkIntent, deps)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerSsoRoutes = registerSsoRoutes;
|
|
4
|
+
/**
|
|
5
|
+
* The two browser-facing SSO routes, kept out of `main.ts`:
|
|
6
|
+
*
|
|
7
|
+
* - `GET /api/auth/sso/finish?bridge=…` — swaps a verified, single-use,
|
|
8
|
+
* browser-bound login bridge for a session / challenge / link
|
|
9
|
+
* (`sso-finish.ts` holds the rules). Limited per REAL client address.
|
|
10
|
+
* Every answer clears the `SSO_STATE_COOKIE`: it binds exactly one finish.
|
|
11
|
+
* - `GET /api/auth/browser-handoff?code=…` — a one-time code from
|
|
12
|
+
* `auth.createBrowserHandoff` opens the account page signed in (D801).
|
|
13
|
+
*/
|
|
14
|
+
const types_1 = require("@camstack/types");
|
|
15
|
+
const spa_mount_js_1 = require("../api/static/spa-mount.js");
|
|
16
|
+
const auth_rate_limit_js_1 = require("./auth-rate-limit.js");
|
|
17
|
+
const native_return_js_1 = require("./native-return.js");
|
|
18
|
+
const sso_finish_js_1 = require("./sso-finish.js");
|
|
19
|
+
const sso_finish_deps_js_1 = require("./sso-finish-deps.js");
|
|
20
|
+
/** A User-Agent as a session's client label. */
|
|
21
|
+
function clientOf(ua) {
|
|
22
|
+
const value = Array.isArray(ua) ? ua[0] : ua;
|
|
23
|
+
return typeof value === 'string' && value.length > 0 ? { client: value.slice(0, 200) } : {};
|
|
24
|
+
}
|
|
25
|
+
/** Expire the state cookie (both Secure variants are cleared by name + path). */
|
|
26
|
+
function clearSsoStateCookie() {
|
|
27
|
+
return `${types_1.SSO_STATE_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
|
|
28
|
+
}
|
|
29
|
+
function registerSsoRoutes(fastify, deps) {
|
|
30
|
+
fastify.get('/api/auth/sso/finish', async (request, reply) => {
|
|
31
|
+
const client = (0, auth_rate_limit_js_1.clientKeyFromRequest)(request, deps.network);
|
|
32
|
+
if (client !== null) {
|
|
33
|
+
const verdict = deps.limiter.check(`sso-finish:${client}`);
|
|
34
|
+
if (!verdict.allowed) {
|
|
35
|
+
reply.header('retry-after', String(verdict.retryAfterSeconds));
|
|
36
|
+
return reply.status(429).send({ error: 'Too many attempts' });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const outcome = await (0, sso_finish_js_1.finishSsoLogin)(request.query.bridge, (0, sso_finish_deps_js_1.createSsoFinishDeps)({
|
|
40
|
+
...deps.wiring,
|
|
41
|
+
sessionIssue: () => {
|
|
42
|
+
const ip = deps.network.clientAddress(request).ip;
|
|
43
|
+
return { ...(ip !== null ? { ip } : {}), ...clientOf(request.headers['user-agent']) };
|
|
44
|
+
},
|
|
45
|
+
trustedNetworkLogin: () => deps.network.isTrustedNetworkLogin(deps.network.clientAddress(request)),
|
|
46
|
+
logFailure: (error) => deps.logger.error('SSO finish failed', {
|
|
47
|
+
meta: { error: error instanceof Error ? error.message : String(error) },
|
|
48
|
+
}),
|
|
49
|
+
}, request.headers.cookie),
|
|
50
|
+
// A popup sign-in (D791) returns through the admin-ui completion page.
|
|
51
|
+
{ popup: request.query.popup === '1' });
|
|
52
|
+
// One state cookie binds one finish, whatever the outcome.
|
|
53
|
+
reply.header('Set-Cookie', clearSsoStateCookie());
|
|
54
|
+
reply.header('Referrer-Policy', 'no-referrer');
|
|
55
|
+
if (outcome.kind === 'error')
|
|
56
|
+
return reply.status(outcome.status).send(outcome.body);
|
|
57
|
+
reply.code(302);
|
|
58
|
+
// Admin-ui paths are relative to the hub's mount (HA ingress etc.); a
|
|
59
|
+
// native app return (`camstack://…`, D801) is not a hub path at all.
|
|
60
|
+
const mount = (0, spa_mount_js_1.parseForwardedPrefix)(request.headers['x-forwarded-prefix']) ?? '';
|
|
61
|
+
reply.header('Location', (0, native_return_js_1.ssoReturnLocation)(mount, outcome.location));
|
|
62
|
+
return reply.send('');
|
|
63
|
+
});
|
|
64
|
+
// A one-time code minted by `auth.createBrowserHandoff` opens the account
|
|
65
|
+
// page signed in (D801): the session rides the URL FRAGMENT (never a server
|
|
66
|
+
// log), and the only destination is the account page.
|
|
67
|
+
fastify.get('/api/auth/browser-handoff', async (request, reply) => {
|
|
68
|
+
const mount = (0, spa_mount_js_1.parseForwardedPrefix)(request.headers['x-forwarded-prefix']) ?? '';
|
|
69
|
+
const grant = typeof request.query.code === 'string'
|
|
70
|
+
? deps.browserHandoffCodes.redeem(request.query.code)
|
|
71
|
+
: null;
|
|
72
|
+
let session = null;
|
|
73
|
+
if (grant !== null) {
|
|
74
|
+
try {
|
|
75
|
+
// A session of its own for this browser (the admin-ui refreshes).
|
|
76
|
+
const ip = deps.network.clientAddress(request).ip;
|
|
77
|
+
session = await deps.minter.mintForUserId(grant.userId, {
|
|
78
|
+
method: 'handoff',
|
|
79
|
+
refreshable: true,
|
|
80
|
+
...(ip !== null ? { ip } : {}),
|
|
81
|
+
...clientOf(request.headers['user-agent']),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
deps.logger.warn('Browser handoff refused', {
|
|
86
|
+
meta: { error: error instanceof Error ? error.message : String(error) },
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
reply.code(302);
|
|
91
|
+
reply.header('Referrer-Policy', 'no-referrer');
|
|
92
|
+
reply.header('Location', (0, native_return_js_1.browserHandoffLocation)(mount, session));
|
|
93
|
+
return reply.send('');
|
|
94
|
+
});
|
|
95
|
+
}
|