@anchrd/intel-api 0.3.3 → 0.4.0
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/README.md
CHANGED
|
@@ -72,8 +72,18 @@ uses PKCE and dynamic public-client registration; browser access tokens stay ins
|
|
|
72
72
|
`node_modules/@anchrd/intel-api/examples/dev.vars.example` to `.dev.vars` for a local customer Worker.
|
|
73
73
|
|
|
74
74
|
The portal endpoint exposes RFC 9728 metadata. The Tools UI follows that metadata, dynamically
|
|
75
|
-
registers with Gate or Cloudflare Access, and completes a separate PKCE flow
|
|
76
|
-
|
|
75
|
+
registers with Gate or Cloudflare Access, and completes a separate PKCE flow — silently, with
|
|
76
|
+
`prompt=none`, as soon as a Gate session exists. Nobody is asked to connect anything.
|
|
77
|
+
|
|
78
|
+
⚠️ **Two settings outside this repository decide whether that silent sign-in can work.** In
|
|
79
|
+
Cloudflare Zero Trust → Access controls → AI controls → your portal → Edit → Advanced settings,
|
|
80
|
+
`Managed OAuth` must be enabled, and an Access policy must carry the people who use Intel. Without
|
|
81
|
+
both, every silent sign-in is refused and the Tools area shows "No access to the company portal" —
|
|
82
|
+
correct behaviour for somebody outside every policy, and a misleading one for a deployment that
|
|
83
|
+
simply never enabled the setting.
|
|
84
|
+
|
|
85
|
+
The only tool secret Intel stores is the resulting per-user access token for its own portal
|
|
86
|
+
endpoint — one per person, never one shared operator token — sealed with a key
|
|
77
87
|
derived from `INTEL_SESSION_SECRET` and kept in the `portal_tokens` table of your D1; provider
|
|
78
88
|
credentials stay with the portal and never reach Intel. The Intel audience token is never forwarded
|
|
79
89
|
to another OAuth resource.
|
|
@@ -27,6 +27,9 @@ const ConnectionPendingSession = z.strictObject({
|
|
|
27
27
|
resource: z.url(),
|
|
28
28
|
userId: z.string().min(1),
|
|
29
29
|
user: UserSession,
|
|
30
|
+
// Older cookies carry no `silent`, and one in flight across a deploy must not become an invalid
|
|
31
|
+
// session: absent means the visible flow, which is what those attempts were.
|
|
32
|
+
silent: z.boolean().default(false),
|
|
30
33
|
expiresAt: z.number().int().positive(),
|
|
31
34
|
});
|
|
32
35
|
const Session = z.discriminatedUnion("kind", [
|
package/dist/auth/auth.js
CHANGED
|
@@ -2,6 +2,22 @@ import { calculatePKCECodeChallenge, randomPKCECodeVerifier, randomState } from
|
|
|
2
2
|
import { IntelError } from "../shared/intel-error/intel-error.js";
|
|
3
3
|
import { SafeReturnPath } from "../shared/safe-return-path/safe-return-path.js";
|
|
4
4
|
const cookieName = "intel_session";
|
|
5
|
+
// What an authorization server answers when `prompt=none` would have worked, but only with somebody
|
|
6
|
+
// looking at a screen. It is the expected answer to a silent attempt, not a broken deployment.
|
|
7
|
+
const InteractionRequired = new Set([
|
|
8
|
+
"login_required",
|
|
9
|
+
"interaction_required",
|
|
10
|
+
"consent_required",
|
|
11
|
+
"account_selection_required",
|
|
12
|
+
]);
|
|
13
|
+
// One fixed marker, never the portal's own words. `error_description` is a sentence written by the
|
|
14
|
+
// authorization server about somebody else's policy: putting it in the URL would hand whatever
|
|
15
|
+
// renders the page next a string Intel does not control.
|
|
16
|
+
function withConnectError(returnTo) {
|
|
17
|
+
const url = new URL(returnTo, "https://intel.invalid");
|
|
18
|
+
url.searchParams.set("connectError", "portal_sign_in_refused");
|
|
19
|
+
return `${url.pathname}${url.search}`;
|
|
20
|
+
}
|
|
5
21
|
function cookieValue(headers) {
|
|
6
22
|
const values = headers.get("cookie")?.split(";") ?? [];
|
|
7
23
|
for (const value of values) {
|
|
@@ -51,6 +67,15 @@ export function createBrowserAuth(deps) {
|
|
|
51
67
|
createdAt: deps.now().toISOString(),
|
|
52
68
|
});
|
|
53
69
|
}
|
|
70
|
+
// The connection handoff parks the Intel session inside the pending cookie, so every way out of
|
|
71
|
+
// the callback has to put it back — the portal attempt must never cost somebody their Intel login.
|
|
72
|
+
async function restore(user, returnTo, location) {
|
|
73
|
+
const remaining = (user.expiresAt - deps.now().getTime()) / 1_000;
|
|
74
|
+
if (remaining <= 0) {
|
|
75
|
+
return redirect(`/auth/login?returnTo=${encodeURIComponent(returnTo)}`, sessionCookie("", secure, 0));
|
|
76
|
+
}
|
|
77
|
+
return redirect(location, sessionCookie(await deps.sessions.seal(user), secure, remaining));
|
|
78
|
+
}
|
|
54
79
|
async function userSession(client, tokens) {
|
|
55
80
|
return {
|
|
56
81
|
version: 1,
|
|
@@ -93,6 +118,10 @@ export function createBrowserAuth(deps) {
|
|
|
93
118
|
throw new IntelError(401, "authentication_required", "Authentication is required");
|
|
94
119
|
}
|
|
95
120
|
const returnTo = SafeReturnPath.catch("/tools").parse(requestUrl.searchParams.get("returnTo") ?? "/tools");
|
|
121
|
+
// Gate is the OIDC provider Cloudflare Access consumes, so whoever holds a valid Intel
|
|
122
|
+
// session is already the person the portal would ask about. `prompt=none` says exactly that:
|
|
123
|
+
// answer from the session that exists, and refuse rather than show anybody a login (#60).
|
|
124
|
+
const silent = requestUrl.searchParams.get("silent") === "1";
|
|
96
125
|
if (!deps.portalUrl) {
|
|
97
126
|
throw new IntelError(503, "portal_not_configured", "No MCP portal is configured for this deployment");
|
|
98
127
|
}
|
|
@@ -113,6 +142,7 @@ export function createBrowserAuth(deps) {
|
|
|
113
142
|
resource: discovered.resource,
|
|
114
143
|
userId,
|
|
115
144
|
user,
|
|
145
|
+
silent,
|
|
116
146
|
expiresAt: deps.now().getTime() + 10 * 60 * 1_000,
|
|
117
147
|
};
|
|
118
148
|
const authorizationUrl = await deps.oauth.authorizationUrl({
|
|
@@ -123,6 +153,7 @@ export function createBrowserAuth(deps) {
|
|
|
123
153
|
codeChallenge,
|
|
124
154
|
state,
|
|
125
155
|
...(discovered.scope ? { scope: discovered.scope } : {}),
|
|
156
|
+
...(silent ? { prompt: "none" } : {}),
|
|
126
157
|
});
|
|
127
158
|
return redirect(authorizationUrl.href, sessionCookie(await deps.sessions.seal(pending), secure, 10 * 60));
|
|
128
159
|
},
|
|
@@ -133,6 +164,19 @@ export function createBrowserAuth(deps) {
|
|
|
133
164
|
pending.expiresAt <= deps.now().getTime()) {
|
|
134
165
|
throw new IntelError(400, "oauth_session_invalid", "OAuth session is missing or expired");
|
|
135
166
|
}
|
|
167
|
+
// ⚠️ An authorization server reports a refusal on the redirect URI, not by failing the token
|
|
168
|
+
// exchange. Reading it here is what keeps a refused silent attempt from surfacing as a raw
|
|
169
|
+
// OAuth error, and it is the only place that knows whether a visible attempt is still owed.
|
|
170
|
+
const refusal = requestUrl.searchParams.get("error");
|
|
171
|
+
if (pending.kind === "connection-pending" && refusal) {
|
|
172
|
+
// The silent attempt only asked whether the sign-in works without a screen. "Not without
|
|
173
|
+
// one" is an answer, so the visible flow runs once — and because that one is not silent, a
|
|
174
|
+
// second refusal ends in the message instead of a third attempt.
|
|
175
|
+
const visibleAttemptLeft = pending.silent && InteractionRequired.has(refusal);
|
|
176
|
+
return await restore(pending.user, pending.returnTo, visibleAttemptLeft
|
|
177
|
+
? `/auth/connect?returnTo=${encodeURIComponent(pending.returnTo)}`
|
|
178
|
+
: withConnectError(pending.returnTo));
|
|
179
|
+
}
|
|
136
180
|
const tokens = await deps.oauth.exchange({
|
|
137
181
|
issuer: pending.kind === "connection-pending" ? pending.issuer : gateIssuer,
|
|
138
182
|
clientId: pending.clientId,
|
|
@@ -151,11 +195,7 @@ export function createBrowserAuth(deps) {
|
|
|
151
195
|
clientId: pending.clientId,
|
|
152
196
|
resource: pending.resource,
|
|
153
197
|
});
|
|
154
|
-
|
|
155
|
-
if (remaining <= 0) {
|
|
156
|
-
return redirect(`/auth/login?returnTo=${encodeURIComponent(pending.returnTo)}`, sessionCookie("", secure, 0));
|
|
157
|
-
}
|
|
158
|
-
return redirect(pending.returnTo, sessionCookie(await deps.sessions.seal(pending.user), secure, remaining));
|
|
198
|
+
return await restore(pending.user, pending.returnTo, pending.returnTo);
|
|
159
199
|
}
|
|
160
200
|
const session = await userSession(pending.clientId, tokens);
|
|
161
201
|
return redirect(pending.returnTo, sessionCookie(await deps.sessions.seal(session), secure, (session.expiresAt - deps.now().getTime()) / 1_000));
|
|
@@ -24,6 +24,7 @@ export interface OAuthPort {
|
|
|
24
24
|
codeChallenge: string;
|
|
25
25
|
state: string;
|
|
26
26
|
scope?: string;
|
|
27
|
+
prompt?: "none";
|
|
27
28
|
}): Promise<URL>;
|
|
28
29
|
exchange(input: {
|
|
29
30
|
issuer: string;
|
|
@@ -76,6 +77,7 @@ export type AuthSession = {
|
|
|
76
77
|
resource: string;
|
|
77
78
|
userId: string;
|
|
78
79
|
user: UserAuthSession;
|
|
80
|
+
silent: boolean;
|
|
79
81
|
expiresAt: number;
|
|
80
82
|
} | UserAuthSession;
|
|
81
83
|
export interface SessionCodec {
|
package/dist/intel/intel.js
CHANGED
|
@@ -32,7 +32,9 @@ export function createIntel(deps) {
|
|
|
32
32
|
const browserAuth = deps.auth;
|
|
33
33
|
if (browserAuth) {
|
|
34
34
|
app.get("/auth/login", async (context) => await browserAuth.login(new URL(context.req.url)));
|
|
35
|
-
// One connect route for the one portal: there are no per-source connections any more.
|
|
35
|
+
// One connect route for the one portal: there are no per-source connections any more. With
|
|
36
|
+
// `?silent=1` it runs `prompt=none`, which is how the Tools screen reaches it without anybody
|
|
37
|
+
// clicking (#60); the route itself is unchanged otherwise, including who may use it.
|
|
36
38
|
app.get("/auth/connect", async (context) => {
|
|
37
39
|
const connectReturnTo = encodeURIComponent("/auth/connect?returnTo=/tools");
|
|
38
40
|
const session = await browserAuth.resolve(context.req.raw.headers);
|
package/dist/tools/tools.js
CHANGED
|
@@ -12,20 +12,23 @@ export function createTools(deps) {
|
|
|
12
12
|
return deps.portalUrl;
|
|
13
13
|
}
|
|
14
14
|
// Authorization for tools lives entirely in the portal, so "may this user act" reduces to "does
|
|
15
|
-
// this user have a usable portal token".
|
|
15
|
+
// this user have a usable portal token". ⚠️ The token is read per actor and never shared: one
|
|
16
|
+
// operator token for everybody would make every catalog the same one and the portal's Access
|
|
17
|
+
// policies decorative (ADR-0003).
|
|
16
18
|
async function accessToken(actor) {
|
|
17
19
|
const stored = await deps.tokens.read(actor.id);
|
|
18
20
|
if (!stored) {
|
|
19
|
-
throw new IntelError(401, "portal_not_connected", "
|
|
21
|
+
throw new IntelError(401, "portal_not_connected", "The portal has not signed this user in yet");
|
|
20
22
|
}
|
|
21
23
|
if (stored.expiresAt > deps.now().getTime() + RefreshWindowMs)
|
|
22
24
|
return stored.accessToken;
|
|
23
25
|
const refreshed = stored.refreshToken ? await deps.refresh(stored) : null;
|
|
24
26
|
if (!refreshed) {
|
|
25
27
|
// A token that cannot be renewed is dropped: leaving it would keep failing every call with a
|
|
26
|
-
// stale credential
|
|
28
|
+
// stale credential. The browser answers this by signing in silently again (#60); an MCP
|
|
29
|
+
// client sees the code and repeats its own authorization.
|
|
27
30
|
await deps.tokens.clear(actor.id);
|
|
28
|
-
throw new IntelError(401, "portal_reconnect_required", "
|
|
31
|
+
throw new IntelError(401, "portal_reconnect_required", "The portal sign-in for this user has expired");
|
|
29
32
|
}
|
|
30
33
|
await deps.tokens.write(actor.id, refreshed);
|
|
31
34
|
return refreshed.accessToken;
|
|
@@ -83,17 +86,18 @@ export function createTools(deps) {
|
|
|
83
86
|
return {
|
|
84
87
|
async catalog(actor) {
|
|
85
88
|
const stored = await deps.tokens.read(actor.id);
|
|
86
|
-
//
|
|
89
|
+
// No portal sign-in yet is a normal state, not an error: the browser answers it by running
|
|
90
|
+
// the silent sign-in and asking again (#60).
|
|
87
91
|
if (!stored)
|
|
88
92
|
return { portalConnected: false, items: [] };
|
|
89
93
|
try {
|
|
90
94
|
return { portalConnected: true, items: await capabilities(actor) };
|
|
91
95
|
}
|
|
92
96
|
catch (error) {
|
|
93
|
-
// A token that is gone or beyond renewal is the same answer as never having
|
|
97
|
+
// A token that is gone or beyond renewal is the same answer as never having signed in, so
|
|
94
98
|
// reading the catalog reports it as a state. Only a portal that does not answer stays an
|
|
95
|
-
// error — the view has to tell "
|
|
96
|
-
//
|
|
99
|
+
// error — the view has to tell "sign in again" apart from "the portal failed", and a 401
|
|
100
|
+
// here would otherwise look like an expired Intel session to the browser.
|
|
97
101
|
if (error instanceof IntelError &&
|
|
98
102
|
(error.code === "portal_not_connected" || error.code === "portal_reconnect_required")) {
|
|
99
103
|
return { portalConnected: false, items: [] };
|