@anchrd/intel-api 0.6.1 → 0.6.2
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.
|
@@ -30,6 +30,9 @@ const ConnectionPendingSession = z.strictObject({
|
|
|
30
30
|
// Older cookies carry no `silent`, and one in flight across a deploy must not become an invalid
|
|
31
31
|
// session: absent means the visible flow, which is what those attempts were.
|
|
32
32
|
silent: z.boolean().default(false),
|
|
33
|
+
// Same for `resumed`: absent means a first attempt, so a handoff written before the deploy still
|
|
34
|
+
// gets its one restart rather than dying at the parser.
|
|
35
|
+
resumed: z.boolean().default(false),
|
|
33
36
|
expiresAt: z.number().int().positive(),
|
|
34
37
|
});
|
|
35
38
|
const Session = z.discriminatedUnion("kind", [
|
package/dist/auth/auth.js
CHANGED
|
@@ -3,6 +3,14 @@ import { IntelError } from "../shared/intel-error/intel-error.js";
|
|
|
3
3
|
import { reportUnexpectedError } from "../shared/report-unexpected-error/report-unexpected-error.js";
|
|
4
4
|
import { SafeReturnPath } from "../shared/safe-return-path/safe-return-path.js";
|
|
5
5
|
const cookieName = "intel_session";
|
|
6
|
+
// ⚠️ The cookie outlives the handoff on purpose. Ten minutes is right for something carrying a PKCE
|
|
7
|
+
// verifier, but the portal's consent screen can stand open longer — it waits for every server to
|
|
8
|
+
// connect, and a person steps away meanwhile. An expired handoff that is still *readable* tells the
|
|
9
|
+
// callback what was being attempted, where the person wanted to go, and whether their Intel session
|
|
10
|
+
// survived; a cookie that vanished with it leaves nothing but a raw error page (#95). `expiresAt`
|
|
11
|
+
// remains the only authority — a stale verifier is never exchanged, no matter how long it is legible.
|
|
12
|
+
const HandoffValidSeconds = 10 * 60;
|
|
13
|
+
const HandoffCookieSeconds = 60 * 60;
|
|
6
14
|
// What an authorization server answers when `prompt=none` would have worked, but only with somebody
|
|
7
15
|
// looking at a screen. It is the expected answer to a silent attempt, not a broken deployment.
|
|
8
16
|
const InteractionRequired = new Set([
|
|
@@ -11,12 +19,14 @@ const InteractionRequired = new Set([
|
|
|
11
19
|
"consent_required",
|
|
12
20
|
"account_selection_required",
|
|
13
21
|
]);
|
|
14
|
-
//
|
|
22
|
+
// Fixed markers, never the portal's own words. `error_description` is a sentence written by the
|
|
15
23
|
// authorization server about somebody else's policy: putting it in the URL would hand whatever
|
|
16
|
-
// renders the page next a string Intel does not control.
|
|
17
|
-
|
|
24
|
+
// renders the page next a string Intel does not control. Which marker matters — a refusal is about
|
|
25
|
+
// access, running out of time is not, and a screen that confuses the two sends the reader hunting
|
|
26
|
+
// in the wrong place (#93).
|
|
27
|
+
function withConnectError(returnTo, code = "portal_sign_in_refused") {
|
|
18
28
|
const url = new URL(returnTo, "https://intel.invalid");
|
|
19
|
-
url.searchParams.set("connectError",
|
|
29
|
+
url.searchParams.set("connectError", code);
|
|
20
30
|
return `${url.pathname}${url.search}`;
|
|
21
31
|
}
|
|
22
32
|
function cookieValue(headers) {
|
|
@@ -131,6 +141,7 @@ export function createBrowserAuth(deps) {
|
|
|
131
141
|
// session is already the person the portal would ask about. `prompt=none` says exactly that:
|
|
132
142
|
// answer from the session that exists, and refuse rather than show anybody a login (#60).
|
|
133
143
|
const silent = requestUrl.searchParams.get("silent") === "1";
|
|
144
|
+
const resumed = requestUrl.searchParams.get("resumed") === "1";
|
|
134
145
|
if (!deps.portalUrl) {
|
|
135
146
|
throw new IntelError(503, "portal_not_configured", "No MCP portal is configured for this deployment");
|
|
136
147
|
}
|
|
@@ -152,7 +163,8 @@ export function createBrowserAuth(deps) {
|
|
|
152
163
|
userId,
|
|
153
164
|
user,
|
|
154
165
|
silent,
|
|
155
|
-
|
|
166
|
+
resumed,
|
|
167
|
+
expiresAt: deps.now().getTime() + HandoffValidSeconds * 1_000,
|
|
156
168
|
};
|
|
157
169
|
const authorizationUrl = await deps.oauth.authorizationUrl({
|
|
158
170
|
issuer: discovered.issuer,
|
|
@@ -164,13 +176,25 @@ export function createBrowserAuth(deps) {
|
|
|
164
176
|
...(discovered.scope ? { scope: discovered.scope } : {}),
|
|
165
177
|
...(silent ? { prompt: "none" } : {}),
|
|
166
178
|
});
|
|
167
|
-
return redirect(authorizationUrl.href, sessionCookie(await deps.sessions.seal(pending), secure,
|
|
179
|
+
return redirect(authorizationUrl.href, sessionCookie(await deps.sessions.seal(pending), secure, HandoffCookieSeconds));
|
|
168
180
|
},
|
|
169
181
|
async callback(requestUrl, headers) {
|
|
170
182
|
const encoded = cookieValue(headers);
|
|
171
183
|
const pending = encoded ? await deps.sessions.open(encoded) : null;
|
|
172
|
-
if (
|
|
173
|
-
|
|
184
|
+
if (pending?.kind !== "pending" && pending?.kind !== "connection-pending") {
|
|
185
|
+
throw new IntelError(400, "oauth_session_invalid", "OAuth session is missing or expired");
|
|
186
|
+
}
|
|
187
|
+
// A handoff that ran out while somebody was still on the consent screen is not a failure they
|
|
188
|
+
// caused, and everything needed to carry on is in it: their parked Intel session and where
|
|
189
|
+
// they were going. `restore` decides which — it sends them back to the Gate login when that
|
|
190
|
+
// session is gone too, rather than into an attempt that could not finish (#95).
|
|
191
|
+
if (pending.kind === "connection-pending" && pending.expiresAt <= deps.now().getTime()) {
|
|
192
|
+
return await restore(pending.user, pending.returnTo, pending.resumed
|
|
193
|
+
? withConnectError(pending.returnTo, "portal_sign_in_expired")
|
|
194
|
+
: `/auth/connect?returnTo=${encodeURIComponent(pending.returnTo)}&resumed=1`);
|
|
195
|
+
}
|
|
196
|
+
// The Gate login handoff parks nothing, so an expired one has nowhere to return to.
|
|
197
|
+
if (pending.expiresAt <= deps.now().getTime()) {
|
|
174
198
|
throw new IntelError(400, "oauth_session_invalid", "OAuth session is missing or expired");
|
|
175
199
|
}
|
|
176
200
|
// ⚠️ An authorization server reports a refusal on the redirect URI, not by failing the token
|