@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.
Files changed (36) hide show
  1. package/dist/agent/agent-mesh-join.js +80 -0
  2. package/dist/agent/main.js +61 -2
  3. package/dist/api/auth-whoami.js +1 -1
  4. package/dist/api/core/auth-admin-tokens.procedures.js +138 -0
  5. package/dist/api/core/auth-limits.js +77 -0
  6. package/dist/api/core/auth-reauth.procedures.js +108 -0
  7. package/dist/api/core/auth-sessions.procedures.js +166 -0
  8. package/dist/api/core/auth.router.js +281 -80
  9. package/dist/api/health/adaptive-probe.routes.js +1 -1
  10. package/dist/api/trpc/core-cap-bridge.js +47 -5
  11. package/dist/api/trpc/generated-cap-mounts.js +19 -1
  12. package/dist/api/trpc/generated-cap-routers.js +512 -205
  13. package/dist/api/trpc/trpc.context.js +86 -34
  14. package/dist/api/trpc/trpc.middleware.js +8 -11
  15. package/dist/api/trpc/trpc.router.js +6 -1
  16. package/dist/auth/auth-network-policy.js +75 -0
  17. package/dist/auth/auth-rate-limit.js +25 -20
  18. package/dist/auth/client-address.js +68 -0
  19. package/dist/auth/external-link-intents.js +12 -3
  20. package/dist/auth/hub-sessions.js +74 -0
  21. package/dist/auth/native-link-completions.js +68 -0
  22. package/dist/auth/native-return.js +33 -0
  23. package/dist/auth/session-lifetimes.js +46 -0
  24. package/dist/auth/session-mint.js +110 -13
  25. package/dist/auth/sso-finish-deps.js +56 -1
  26. package/dist/auth/sso-finish.js +132 -17
  27. package/dist/auth/sso-routes.js +95 -0
  28. package/dist/auth/used-bridge-ids.js +52 -0
  29. package/dist/core/addon/addon-registry.service.js +5 -0
  30. package/dist/core/agent/agent-registry.service.js +10 -0
  31. package/dist/core/auth/auth.service.js +50 -1
  32. package/dist/core/auth/handoff-code.service.js +9 -3
  33. package/dist/core/moleculer/hub-mesh-guard.js +132 -0
  34. package/dist/core/moleculer/moleculer.service.js +33 -0
  35. package/dist/main.js +92 -28
  36. 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 mintForRecord = (record) => ({
39
- token: deps.signer.signToken({
40
- userId: record.id,
41
- username: record.username,
42
- isAdmin: record.isAdmin,
43
- allowedProviders: record.allowedProviders ?? '*',
44
- allowedDevices: record.allowedDevices ?? {},
45
- scopes: record.scopes ?? [],
46
- }),
47
- user: { id: record.id, username: record.username, isAdmin: record.isAdmin },
48
- requiresTotp: false,
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
- await um.linkExternalIdentity(input);
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');
@@ -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 = exports.MAGIC_LINK_BRIDGE_PROVIDER = void 0;
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, and it tells an identity-named bridge
19
- * (`oidc:<instance>:<sub>`, linked users only) from a magic-link bridge that
20
- * names a user record. A bridge with any other provider is refused, so a
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 (claims.provider === exports.MAGIC_LINK_BRIDGE_PROVIDER ||
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 factors = await deps.secondFactors(record.id);
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
- return redirect(`${LOGIN_PATH}#token=${encodeURIComponent(session.token)}&provider=${encodeURIComponent(provider)}`);
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
+ }