@segmentstream/app 0.0.0-stage → 0.38.1
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/LICENSE +201 -0
- package/NOTICE +2 -0
- package/README.md +1236 -2
- package/dist/agent.d.ts +56 -0
- package/dist/agent.js +203 -0
- package/dist/app.d.ts +82 -0
- package/dist/app.js +434 -0
- package/dist/browser-auth-capability.d.ts +2 -0
- package/dist/browser-auth-capability.js +2 -0
- package/dist/browser-revision.d.ts +19 -0
- package/dist/browser-revision.js +140 -0
- package/dist/channel-protocol.d.ts +16 -0
- package/dist/channel-protocol.js +17 -0
- package/dist/channel-session.d.ts +22 -0
- package/dist/channel-session.js +89 -0
- package/dist/config.d.ts +53 -0
- package/dist/config.js +112 -0
- package/dist/context.d.ts +10 -0
- package/dist/context.js +1 -0
- package/dist/cookies.d.ts +56 -0
- package/dist/cookies.js +131 -0
- package/dist/data.d.ts +233 -0
- package/dist/data.js +206 -0
- package/dist/decision-pages.d.ts +14 -0
- package/dist/decision-pages.js +87 -0
- package/dist/deployment.d.ts +30 -0
- package/dist/deployment.js +72 -0
- package/dist/errors.d.ts +60 -0
- package/dist/errors.js +123 -0
- package/dist/federation.d.ts +24 -0
- package/dist/federation.js +613 -0
- package/dist/groups.d.ts +95 -0
- package/dist/groups.js +280 -0
- package/dist/handlers.d.ts +10 -0
- package/dist/handlers.js +161 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.js +29 -0
- package/dist/jobs.d.ts +49 -0
- package/dist/jobs.js +40 -0
- package/dist/legacy.d.ts +77 -0
- package/dist/legacy.js +57 -0
- package/dist/logo.d.ts +1 -0
- package/dist/logo.js +25 -0
- package/dist/me.d.ts +42 -0
- package/dist/me.js +54 -0
- package/dist/navigation/build.d.ts +68 -0
- package/dist/navigation/build.js +290 -0
- package/dist/navigation/cli.d.ts +19 -0
- package/dist/navigation/cli.js +37 -0
- package/dist/navigation/generate.d.ts +43 -0
- package/dist/navigation/generate.js +138 -0
- package/dist/navigation/next.d.ts +10 -0
- package/dist/navigation/next.js +18 -0
- package/dist/navigation-protocol.d.ts +55 -0
- package/dist/navigation-protocol.js +298 -0
- package/dist/neon.d.ts +101 -0
- package/dist/neon.js +539 -0
- package/dist/oauth-completion.d.ts +6 -0
- package/dist/oauth-completion.js +106 -0
- package/dist/oauth.d.ts +59 -0
- package/dist/oauth.js +129 -0
- package/dist/ontology-names.d.ts +10 -0
- package/dist/ontology-names.js +38 -0
- package/dist/ontology-transport.d.ts +34 -0
- package/dist/ontology-transport.js +130 -0
- package/dist/ontology-wire.d.ts +66 -0
- package/dist/ontology-wire.js +355 -0
- package/dist/ontology.d.ts +381 -0
- package/dist/ontology.js +367 -0
- package/dist/pages.d.ts +6 -0
- package/dist/pages.js +6 -0
- package/dist/parameters.d.ts +55 -0
- package/dist/parameters.js +118 -0
- package/dist/permissions.d.ts +19 -0
- package/dist/permissions.js +55 -0
- package/dist/pkce.d.ts +8 -0
- package/dist/pkce.js +40 -0
- package/dist/proxy.d.ts +25 -0
- package/dist/proxy.js +135 -0
- package/dist/request-timing.d.ts +6 -0
- package/dist/request-timing.js +53 -0
- package/dist/return-to.d.ts +2 -0
- package/dist/return-to.js +24 -0
- package/dist/server.d.ts +11 -0
- package/dist/server.js +80 -0
- package/dist/sessions.d.ts +27 -0
- package/dist/sessions.js +72 -0
- package/dist/sign-in.d.ts +37 -0
- package/dist/sign-in.js +95 -0
- package/dist/storage.d.ts +79 -0
- package/dist/storage.js +180 -0
- package/dist/telegram-launch.d.ts +7 -0
- package/dist/telegram-launch.js +134 -0
- package/dist/telegram.d.ts +2 -0
- package/dist/telegram.js +6 -0
- package/dist/tokens.d.ts +51 -0
- package/dist/tokens.js +70 -0
- package/dist/ui/agent-connect.d.ts +18 -0
- package/dist/ui/agent-connect.js +23 -0
- package/dist/ui/components.d.ts +27 -0
- package/dist/ui/components.js +71 -0
- package/dist/ui/hooks.d.ts +19 -0
- package/dist/ui/hooks.js +56 -0
- package/dist/ui/index.d.ts +22 -0
- package/dist/ui/index.js +15 -0
- package/dist/ui/navigation.d.ts +5 -0
- package/dist/ui/navigation.js +8 -0
- package/dist/ui/segmentstream-logo.d.ts +2 -0
- package/dist/ui/segmentstream-logo.js +7 -0
- package/dist/ui/sign-in-check.d.ts +14 -0
- package/dist/ui/sign-in-check.js +41 -0
- package/dist/ui/sign-in.d.ts +17 -0
- package/dist/ui/sign-in.js +65 -0
- package/dist/ui/store.d.ts +80 -0
- package/dist/ui/store.js +280 -0
- package/dist/ui/styles.css +249 -0
- package/dist/ui/telegram-sign-in.d.ts +16 -0
- package/dist/ui/telegram-sign-in.js +21 -0
- package/dist/ui/use-browser-sign-in.d.ts +52 -0
- package/dist/ui/use-browser-sign-in.js +169 -0
- package/dist/ui/use-channel-sign-in.d.ts +10 -0
- package/dist/ui/use-channel-sign-in.js +118 -0
- package/dist/ui/use-sign-in.d.ts +109 -0
- package/dist/ui/use-sign-in.js +304 -0
- package/package.json +95 -4
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
import { cookies, headers } from "next/headers";
|
|
2
|
+
import { redirect } from "next/navigation";
|
|
3
|
+
import { NextResponse } from "next/server";
|
|
4
|
+
import { createBrowserConfigurationCheck, deploymentRefreshRequired, } from "./browser-revision.js";
|
|
5
|
+
import { hasChannelApps } from "./config.js";
|
|
6
|
+
import { cookiePolicyFor, isLoopbackHostname, safeReturnTo } from "./cookies.js";
|
|
7
|
+
import { channelAppFraming } from "./telegram.js";
|
|
8
|
+
import { createChannelSession } from "./channel-session.js";
|
|
9
|
+
import { isSameOrigin, withUnframeablePages } from "./decision-pages.js";
|
|
10
|
+
import { apiErrorFrom, readJson, SegmentStreamApiError } from "./errors.js";
|
|
11
|
+
import { parseMe } from "./me.js";
|
|
12
|
+
import { createOntologyTransport, ontologyOrigin } from "./ontology-transport.js";
|
|
13
|
+
import { createOntologyClient } from "./ontology.js";
|
|
14
|
+
import { createAgentAuthorizations } from "./oauth.js";
|
|
15
|
+
import { createAgentIdentities } from "./agent.js";
|
|
16
|
+
import { isAllowed } from "./permissions.js";
|
|
17
|
+
import { isApiRequest, revisionUnavailableResponse, unauthenticatedResponse } from "./sign-in.js";
|
|
18
|
+
import { matchesPathPrefix, normalizePublicPath } from "./proxy.js";
|
|
19
|
+
import { base64UrlDecode, base64UrlEncode, constantTimeEqual, createCodeChallenge, randomToken, } from "./pkce.js";
|
|
20
|
+
const PREFIX = "/api/segmentstream";
|
|
21
|
+
const CALLBACK = `${PREFIX}/callback`;
|
|
22
|
+
const LINK = `${PREFIX}/link`;
|
|
23
|
+
function safeDestination(value) {
|
|
24
|
+
const destination = safeReturnTo(value);
|
|
25
|
+
const pathname = new URL(destination, "https://return.invalid").pathname;
|
|
26
|
+
return matchesPathPrefix(pathname, PREFIX) ? "/" : destination;
|
|
27
|
+
}
|
|
28
|
+
function displayIdentity(value) {
|
|
29
|
+
if (!value || typeof value !== "object")
|
|
30
|
+
return undefined;
|
|
31
|
+
const identity = value;
|
|
32
|
+
if (typeof identity.id !== "string" || !identity.id || identity.id.length > 512)
|
|
33
|
+
return undefined;
|
|
34
|
+
return {
|
|
35
|
+
id: identity.id,
|
|
36
|
+
...(typeof identity.email === "string" && identity.email.length <= 320
|
|
37
|
+
? { email: identity.email }
|
|
38
|
+
: {}),
|
|
39
|
+
...(typeof identity.name === "string" && identity.name.length <= 200
|
|
40
|
+
? { name: identity.name }
|
|
41
|
+
: {}),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function pendingLink(value) {
|
|
45
|
+
try {
|
|
46
|
+
const pending = decode(value ?? "");
|
|
47
|
+
return typeof pending.token === "string" &&
|
|
48
|
+
pending.token.startsWith("ssfl_") &&
|
|
49
|
+
typeof pending.expiresAt === "number" &&
|
|
50
|
+
pending.expiresAt > Date.now() &&
|
|
51
|
+
displayIdentity(pending.identity)
|
|
52
|
+
? pending
|
|
53
|
+
: undefined;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const encode = (value) => base64UrlEncode(new TextEncoder().encode(JSON.stringify(value)));
|
|
60
|
+
const decode = (value) => JSON.parse(new TextDecoder().decode(base64UrlDecode(value)));
|
|
61
|
+
const cookieName = (workspaceId, secure, purpose = "session") => `${secure ? "__Host-" : ""}ss_oidc_${purpose}.${workspaceId.replace(/[^A-Za-z0-9_-]/g, "_")}`;
|
|
62
|
+
const attributes = (secure, maxAge) => ({
|
|
63
|
+
httpOnly: true,
|
|
64
|
+
secure,
|
|
65
|
+
sameSite: "lax",
|
|
66
|
+
path: "/",
|
|
67
|
+
maxAge,
|
|
68
|
+
});
|
|
69
|
+
/** Public-client RP. Only workspace-owned opaque sessions are stored in its session cookie. */
|
|
70
|
+
export function withFederation(base, config, oidc, options) {
|
|
71
|
+
const issuer = new URL(oidc.issuer);
|
|
72
|
+
if (issuer.protocol !== "https:" ||
|
|
73
|
+
issuer.username ||
|
|
74
|
+
issuer.password ||
|
|
75
|
+
issuer.search ||
|
|
76
|
+
issuer.hash ||
|
|
77
|
+
oidc.issuer.endsWith("/")) {
|
|
78
|
+
throw new Error("SEGMENTSTREAM_OIDC_ISSUER must be an HTTPS issuer without a trailing slash.");
|
|
79
|
+
}
|
|
80
|
+
const checkConfiguration = options.browserAuth && options.browserRevision
|
|
81
|
+
? createBrowserConfigurationCheck(options.fetch, options.browserAuth, options.browserRevision, options.ontologyUrl)
|
|
82
|
+
: undefined;
|
|
83
|
+
async function verifyConfiguration() {
|
|
84
|
+
if (!options.browserAuth)
|
|
85
|
+
return;
|
|
86
|
+
if (!checkConfiguration)
|
|
87
|
+
throw deploymentRefreshRequired();
|
|
88
|
+
await checkConfiguration();
|
|
89
|
+
}
|
|
90
|
+
async function configurationFailure(request) {
|
|
91
|
+
try {
|
|
92
|
+
await verifyConfiguration();
|
|
93
|
+
}
|
|
94
|
+
catch (cause) {
|
|
95
|
+
return revisionUnavailableResponse(request, cause);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const signInPage = (request, error) => {
|
|
99
|
+
const destination = new URL("/sign-in", request.url);
|
|
100
|
+
destination.searchParams.set("returnTo", safeReturnTo(`${request.nextUrl.pathname}${request.nextUrl.search}`));
|
|
101
|
+
if (error)
|
|
102
|
+
destination.searchParams.set("error", error);
|
|
103
|
+
return NextResponse.redirect(destination);
|
|
104
|
+
};
|
|
105
|
+
const channel = createChannelSession({
|
|
106
|
+
enabled: hasChannelApps(config.channels),
|
|
107
|
+
workspaceId: config.workspaceId,
|
|
108
|
+
ontologyUrl: options.ontologyUrl,
|
|
109
|
+
fetch: options.fetch,
|
|
110
|
+
});
|
|
111
|
+
const token = async () => {
|
|
112
|
+
const cookieStore = await cookies();
|
|
113
|
+
const host = (await headers()).get("host") ?? "";
|
|
114
|
+
const hostname = URL.parse(`http://${host}`)?.hostname ?? "";
|
|
115
|
+
const secure = cookieStore.get(cookieName(config.workspaceId, true))?.value;
|
|
116
|
+
return (secure ??
|
|
117
|
+
(isLoopbackHostname(hostname)
|
|
118
|
+
? cookieStore.get(cookieName(config.workspaceId, false))?.value
|
|
119
|
+
: undefined) ??
|
|
120
|
+
null);
|
|
121
|
+
};
|
|
122
|
+
const transport = createOntologyTransport({
|
|
123
|
+
fetch: options.fetch,
|
|
124
|
+
ontologyUrl: options.ontologyUrl,
|
|
125
|
+
credentials: { token },
|
|
126
|
+
});
|
|
127
|
+
const fallbackTransport = options.proofTransport ?? options.channelSession?.transport ?? transport;
|
|
128
|
+
const ontologyTransport = {
|
|
129
|
+
fetch: async (path, init = {}) => (await channel.read())
|
|
130
|
+
? channel.transport.fetch(path, init)
|
|
131
|
+
: (await token())
|
|
132
|
+
? transport.fetch(path, init)
|
|
133
|
+
: fallbackTransport.fetch(path, init),
|
|
134
|
+
};
|
|
135
|
+
// OIDC and channel sessions read `/me` on every call, so `fresh` changes nothing for them.
|
|
136
|
+
async function getUser(userOptions = {}) {
|
|
137
|
+
await verifyConfiguration();
|
|
138
|
+
const channelUser = (await channel.read())?.user;
|
|
139
|
+
if (channelUser) {
|
|
140
|
+
return {
|
|
141
|
+
...channelUser,
|
|
142
|
+
email: channelUser.email ?? undefined,
|
|
143
|
+
name: channelUser.name ?? undefined,
|
|
144
|
+
organizationId: config.organizationId,
|
|
145
|
+
workspaceId: config.workspaceId,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (!(await token())) {
|
|
149
|
+
return options.neon ? base.getUser(userOptions) : (options.channelSession?.getUser() ?? null);
|
|
150
|
+
}
|
|
151
|
+
return oidcUser();
|
|
152
|
+
}
|
|
153
|
+
async function oidcUser() {
|
|
154
|
+
return userFromResponse(await transport.fetch("/me", { cache: "no-store" }));
|
|
155
|
+
}
|
|
156
|
+
async function userFromResponse(response) {
|
|
157
|
+
if (response.status === 401 || response.status === 403) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
let payload;
|
|
161
|
+
try {
|
|
162
|
+
payload = await response.json();
|
|
163
|
+
}
|
|
164
|
+
catch (cause) {
|
|
165
|
+
throw new SegmentStreamApiError({
|
|
166
|
+
code: "ontology_invalid_response",
|
|
167
|
+
status: 502,
|
|
168
|
+
detail: "ontology_invalid_response",
|
|
169
|
+
cause,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
if (!response.ok) {
|
|
173
|
+
throw apiErrorFrom(response.status, payload, "session_unavailable");
|
|
174
|
+
}
|
|
175
|
+
const user = parseMe(payload);
|
|
176
|
+
if (!user) {
|
|
177
|
+
throw new SegmentStreamApiError({
|
|
178
|
+
code: "ontology_invalid_response",
|
|
179
|
+
status: 502,
|
|
180
|
+
detail: "ontology_invalid_response",
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
id: user.id,
|
|
185
|
+
...(user.email ? { email: user.email } : {}),
|
|
186
|
+
...(user.name ? { name: user.name } : {}),
|
|
187
|
+
organizationId: config.organizationId,
|
|
188
|
+
workspaceId: config.workspaceId,
|
|
189
|
+
roles: user.roles,
|
|
190
|
+
permissions: user.permissions,
|
|
191
|
+
...(user.scopes === undefined ? {} : { scopes: user.scopes }),
|
|
192
|
+
kind: user.kind,
|
|
193
|
+
active: user.active,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
const setSession = (response, request, value) => {
|
|
197
|
+
const { secure } = cookiePolicyFor(new URL(request.url), config.workspaceId);
|
|
198
|
+
response.cookies.set(cookieName(config.workspaceId, secure), value, attributes(secure, 30 * 86400));
|
|
199
|
+
};
|
|
200
|
+
async function endFederationSession() {
|
|
201
|
+
if (!(await token())) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const response = await transport.fetch("/federation/logout", { method: "POST" });
|
|
205
|
+
if (!response.ok) {
|
|
206
|
+
throw apiErrorFrom(response.status, await readJson(response), "logout_failed");
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
async function GET(request) {
|
|
210
|
+
const { secure } = cookiePolicyFor(new URL(request.url), config.workspaceId);
|
|
211
|
+
const flowPrefix = cookieName(config.workspaceId, secure, "flow");
|
|
212
|
+
const pendingName = cookieName(config.workspaceId, secure, "pending");
|
|
213
|
+
const path = request.nextUrl.pathname;
|
|
214
|
+
const failure = await configurationFailure(request);
|
|
215
|
+
if (failure)
|
|
216
|
+
return failure;
|
|
217
|
+
if (path === `${PREFIX}/configuration`) {
|
|
218
|
+
return NextResponse.json({
|
|
219
|
+
enabled: true,
|
|
220
|
+
neon: options.neon,
|
|
221
|
+
channels: config.channels ?? {},
|
|
222
|
+
...(options.browserAuth ? { browser: options.browserAuth.browser } : {}),
|
|
223
|
+
}, { headers: { "cache-control": "no-store" } });
|
|
224
|
+
}
|
|
225
|
+
if (path === `${PREFIX}/session`) {
|
|
226
|
+
const credential = await token();
|
|
227
|
+
if (!credential) {
|
|
228
|
+
return NextResponse.json(null, { status: 401, headers: { "cache-control": "no-store" } });
|
|
229
|
+
}
|
|
230
|
+
const user = await oidcUser();
|
|
231
|
+
return NextResponse.json(user
|
|
232
|
+
? {
|
|
233
|
+
session: { id: await createCodeChallenge(credential) },
|
|
234
|
+
user,
|
|
235
|
+
source: "oidc",
|
|
236
|
+
}
|
|
237
|
+
: { error: "unauthorized", source: "oidc" }, {
|
|
238
|
+
status: user ? 200 : 401,
|
|
239
|
+
headers: { "cache-control": "no-store" },
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
if (path === `${PREFIX}/sign-in` || path === `${PREFIX}/connect`) {
|
|
243
|
+
if (request.method !== "GET" ||
|
|
244
|
+
request.headers.has("next-router-prefetch") ||
|
|
245
|
+
request.headers.has("purpose") ||
|
|
246
|
+
request.nextUrl.searchParams.has("_rsc"))
|
|
247
|
+
return unauthenticatedResponse();
|
|
248
|
+
let initiatingUser = null;
|
|
249
|
+
if (path === `${PREFIX}/connect`) {
|
|
250
|
+
if (!options.neon || !options.identityToken)
|
|
251
|
+
return NextResponse.json({ error: "identity_method_unavailable" }, { status: 403 });
|
|
252
|
+
initiatingUser = await base.getUser({ fresh: true });
|
|
253
|
+
if (!initiatingUser ||
|
|
254
|
+
initiatingUser.active === false ||
|
|
255
|
+
initiatingUser.kind === "service" ||
|
|
256
|
+
!(await options.identityToken()))
|
|
257
|
+
return NextResponse.json({ error: "identity_proof_required" }, { status: 401 });
|
|
258
|
+
}
|
|
259
|
+
const state = randomToken();
|
|
260
|
+
const flow = {
|
|
261
|
+
state,
|
|
262
|
+
nonce: randomToken(),
|
|
263
|
+
verifier: randomToken(),
|
|
264
|
+
redirectUri: `${request.nextUrl.origin}${CALLBACK}`,
|
|
265
|
+
returnTo: safeDestination(request.nextUrl.searchParams.get("returnTo")),
|
|
266
|
+
expiresAt: Date.now() + 600_000,
|
|
267
|
+
...(initiatingUser ? { intent: "link", workspaceUserId: initiatingUser.id } : {}),
|
|
268
|
+
};
|
|
269
|
+
const destination = new URL(`${oidc.issuer}/authorize`);
|
|
270
|
+
destination.search = new URLSearchParams({
|
|
271
|
+
response_type: "code",
|
|
272
|
+
client_id: oidc.clientId,
|
|
273
|
+
redirect_uri: flow.redirectUri,
|
|
274
|
+
scope: "openid profile email",
|
|
275
|
+
state,
|
|
276
|
+
nonce: flow.nonce,
|
|
277
|
+
code_challenge_method: "S256",
|
|
278
|
+
code_challenge: await createCodeChallenge(flow.verifier),
|
|
279
|
+
}).toString();
|
|
280
|
+
const navigation = request.nextUrl.searchParams.get("account") === "select"
|
|
281
|
+
? new URL("/auth/sign-in", issuer.origin)
|
|
282
|
+
: destination;
|
|
283
|
+
if (navigation !== destination) {
|
|
284
|
+
navigation.searchParams.set("returnTo", `${destination.pathname}${destination.search}`);
|
|
285
|
+
navigation.searchParams.set("intent", "app");
|
|
286
|
+
navigation.searchParams.set("reauthenticate", "1");
|
|
287
|
+
}
|
|
288
|
+
const response = NextResponse.redirect(navigation);
|
|
289
|
+
const pending = request.cookies
|
|
290
|
+
.getAll()
|
|
291
|
+
.filter((cookie) => cookie.name.startsWith(`${flowPrefix}.`));
|
|
292
|
+
if (pending.length >= 4) {
|
|
293
|
+
for (const cookie of pending) {
|
|
294
|
+
response.cookies.set(cookie.name, "", attributes(secure, 0));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
response.cookies.set(`${flowPrefix}.${state}`, encode(flow), attributes(secure, 600));
|
|
298
|
+
return response;
|
|
299
|
+
}
|
|
300
|
+
if (path === CALLBACK) {
|
|
301
|
+
const params = request.nextUrl.searchParams;
|
|
302
|
+
const state = params.get("state") ?? "";
|
|
303
|
+
const flowName = /^[A-Za-z0-9_-]{43}$/.test(state) ? `${flowPrefix}.${state}` : "";
|
|
304
|
+
const failed = NextResponse.redirect(new URL("/sign-in?error=segmentstream_sign_in_failed", request.url));
|
|
305
|
+
if (flowName) {
|
|
306
|
+
failed.cookies.set(flowName, "", attributes(secure, 0));
|
|
307
|
+
}
|
|
308
|
+
try {
|
|
309
|
+
const value = request.cookies.get(flowName)?.value;
|
|
310
|
+
const flow = value ? decode(value) : undefined;
|
|
311
|
+
if (typeof flow?.returnTo === "string") {
|
|
312
|
+
const recovery = new URL(failed.headers.get("location"));
|
|
313
|
+
recovery.searchParams.set("returnTo", safeReturnTo(flow.returnTo));
|
|
314
|
+
failed.headers.set("location", recovery.href);
|
|
315
|
+
}
|
|
316
|
+
if (!flow ||
|
|
317
|
+
[...params.keys()].some((key) => params.getAll(key).length !== 1) ||
|
|
318
|
+
typeof flow.state !== "string" ||
|
|
319
|
+
!constantTimeEqual(flow.state, state) ||
|
|
320
|
+
flow.redirectUri !== `${request.nextUrl.origin}${CALLBACK}` ||
|
|
321
|
+
typeof flow.expiresAt !== "number" ||
|
|
322
|
+
flow.expiresAt <= Date.now() ||
|
|
323
|
+
typeof flow.verifier !== "string" ||
|
|
324
|
+
typeof flow.nonce !== "string" ||
|
|
325
|
+
params.get("iss") !== oidc.issuer ||
|
|
326
|
+
!params.get("code") ||
|
|
327
|
+
params.has("error")) {
|
|
328
|
+
return failed;
|
|
329
|
+
}
|
|
330
|
+
let proof;
|
|
331
|
+
if (flow.intent === "link") {
|
|
332
|
+
const current = options.neon ? await base.getUser({ fresh: true }) : null;
|
|
333
|
+
proof = await options.identityToken?.();
|
|
334
|
+
if (!current ||
|
|
335
|
+
current.active === false ||
|
|
336
|
+
current.kind === "service" ||
|
|
337
|
+
current.id !== flow.workspaceUserId ||
|
|
338
|
+
!proof)
|
|
339
|
+
return failed;
|
|
340
|
+
}
|
|
341
|
+
else if (flow.intent !== undefined)
|
|
342
|
+
return failed;
|
|
343
|
+
const response = await options.fetch(`${await ontologyOrigin(options.ontologyUrl)}/api/ontology/federation/exchange`, {
|
|
344
|
+
method: "POST",
|
|
345
|
+
headers: { "content-type": "application/json" },
|
|
346
|
+
redirect: "error",
|
|
347
|
+
cache: "no-store",
|
|
348
|
+
signal: AbortSignal.timeout(15000),
|
|
349
|
+
body: JSON.stringify({
|
|
350
|
+
code: params.get("code"),
|
|
351
|
+
verifier: flow.verifier,
|
|
352
|
+
nonce: flow.nonce,
|
|
353
|
+
redirectUri: flow.redirectUri,
|
|
354
|
+
...(flow.intent === "link" ? { intent: "link", proof } : {}),
|
|
355
|
+
}),
|
|
356
|
+
});
|
|
357
|
+
const result = (await response.json());
|
|
358
|
+
if (!response.ok ||
|
|
359
|
+
typeof result.token !== "string" ||
|
|
360
|
+
(flow.intent === "link" &&
|
|
361
|
+
(result.status !== "proof_required" || result.linkUserId !== flow.workspaceUserId))) {
|
|
362
|
+
return failed;
|
|
363
|
+
}
|
|
364
|
+
const returnTo = safeDestination(typeof flow.returnTo === "string" ? flow.returnTo : "/");
|
|
365
|
+
const destination = result.status === "proof_required"
|
|
366
|
+
? `/sign-in/link?returnTo=${encodeURIComponent(returnTo)}`
|
|
367
|
+
: returnTo;
|
|
368
|
+
const completed = NextResponse.redirect(new URL(destination, request.url));
|
|
369
|
+
completed.cookies.set(flowName, "", attributes(secure, 0));
|
|
370
|
+
if (result.status === "proof_required" && result.token.startsWith("ssfl_")) {
|
|
371
|
+
const identity = displayIdentity(result.identity);
|
|
372
|
+
if (!identity)
|
|
373
|
+
return failed;
|
|
374
|
+
completed.cookies.set(pendingName, encode({
|
|
375
|
+
token: result.token,
|
|
376
|
+
returnTo,
|
|
377
|
+
identity,
|
|
378
|
+
expiresAt: Date.now() + 600_000,
|
|
379
|
+
...(flow.intent === "link" ? { workspaceUserId: flow.workspaceUserId } : {}),
|
|
380
|
+
}), attributes(secure, 600));
|
|
381
|
+
}
|
|
382
|
+
else if (result.status === "signed_in" && result.token.startsWith("ssfs_")) {
|
|
383
|
+
setSession(completed, request, result.token);
|
|
384
|
+
}
|
|
385
|
+
else {
|
|
386
|
+
return failed;
|
|
387
|
+
}
|
|
388
|
+
return completed;
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
return failed;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (path === LINK) {
|
|
395
|
+
const pending = pendingLink(request.cookies.get(pendingName)?.value);
|
|
396
|
+
const current = options.neon ? await base.getUser({ fresh: true }) : null;
|
|
397
|
+
const sameUser = !pending?.workspaceUserId || pending.workspaceUserId === current?.id;
|
|
398
|
+
const ready = !!pending &&
|
|
399
|
+
!!current &&
|
|
400
|
+
current.active !== false &&
|
|
401
|
+
current.kind !== "service" &&
|
|
402
|
+
sameUser;
|
|
403
|
+
const response = NextResponse.json({
|
|
404
|
+
pending: !!pending && sameUser,
|
|
405
|
+
email_enabled: options.neon,
|
|
406
|
+
proof_ready: ready,
|
|
407
|
+
...(pending && sameUser
|
|
408
|
+
? {
|
|
409
|
+
identity: displayIdentity(pending.identity),
|
|
410
|
+
expiresAt: pending.expiresAt,
|
|
411
|
+
returnTo: safeDestination(typeof pending.returnTo === "string" ? pending.returnTo : "/"),
|
|
412
|
+
}
|
|
413
|
+
: {}),
|
|
414
|
+
...(ready ? { workspaceUser: displayIdentity(current) } : {}),
|
|
415
|
+
}, { headers: { "cache-control": "no-store" } });
|
|
416
|
+
if (ready)
|
|
417
|
+
response.cookies.set(pendingName, encode({ ...pending, reviewedWorkspaceUserId: current.id }), attributes(secure, Math.max(0, Math.floor((Number(pending.expiresAt) - Date.now()) / 1000))));
|
|
418
|
+
return response;
|
|
419
|
+
}
|
|
420
|
+
return new Response("Not found", { status: 404 });
|
|
421
|
+
}
|
|
422
|
+
async function POST(request) {
|
|
423
|
+
const failure = await configurationFailure(request);
|
|
424
|
+
if (failure)
|
|
425
|
+
return failure;
|
|
426
|
+
if (!isSameOrigin(request)) {
|
|
427
|
+
return NextResponse.json({ error: "cross_origin_session" }, { status: 403 });
|
|
428
|
+
}
|
|
429
|
+
if (request.nextUrl.pathname === `${PREFIX}/sign-out`) {
|
|
430
|
+
await endFederationSession();
|
|
431
|
+
const response = NextResponse.json({ signed_out: true });
|
|
432
|
+
for (const secure of [true, false]) {
|
|
433
|
+
response.cookies.set(cookieName(config.workspaceId, secure), "", attributes(secure, 0));
|
|
434
|
+
}
|
|
435
|
+
return response;
|
|
436
|
+
}
|
|
437
|
+
if (request.nextUrl.pathname === `${PREFIX}/link-email` && options.neon) {
|
|
438
|
+
const proof = await options.identityToken?.();
|
|
439
|
+
if (!proof || !(await token()))
|
|
440
|
+
return NextResponse.json({ error: "identity_proof_required" }, { status: 401 });
|
|
441
|
+
const response = await transport.fetch("/federation/link-email", {
|
|
442
|
+
method: "POST",
|
|
443
|
+
headers: { "content-type": "application/json" },
|
|
444
|
+
body: JSON.stringify({ proof }),
|
|
445
|
+
});
|
|
446
|
+
const result = await readJson(response);
|
|
447
|
+
const mismatch = result !== null &&
|
|
448
|
+
typeof result === "object" &&
|
|
449
|
+
"error" in result &&
|
|
450
|
+
result.error === "email_identity_mismatch";
|
|
451
|
+
return NextResponse.json(response.ok
|
|
452
|
+
? { linked: true }
|
|
453
|
+
: { error: mismatch ? "email_identity_mismatch" : "identity_link_failed" }, {
|
|
454
|
+
status: response.ok ? 200 : response.status,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
const { secure } = cookiePolicyFor(new URL(request.url), config.workspaceId);
|
|
458
|
+
const pendingName = cookieName(config.workspaceId, secure, "pending");
|
|
459
|
+
if (request.nextUrl.pathname !== LINK || !options.neon) {
|
|
460
|
+
return new Response("Not found", { status: 404 });
|
|
461
|
+
}
|
|
462
|
+
try {
|
|
463
|
+
const pending = pendingLink(request.cookies.get(pendingName)?.value);
|
|
464
|
+
const current = await base.getUser({ fresh: true });
|
|
465
|
+
if (!pending ||
|
|
466
|
+
!current ||
|
|
467
|
+
current.active === false ||
|
|
468
|
+
current.kind === "service" ||
|
|
469
|
+
pending.reviewedWorkspaceUserId !== current.id ||
|
|
470
|
+
(pending.workspaceUserId && pending.workspaceUserId !== current.id))
|
|
471
|
+
return NextResponse.json({ error: "identity_proof_changed" }, { status: 403 });
|
|
472
|
+
const response = await options.proofTransport.fetch("/federation/link", {
|
|
473
|
+
method: "POST",
|
|
474
|
+
headers: { "content-type": "application/json" },
|
|
475
|
+
body: JSON.stringify({ pending: pending.token }),
|
|
476
|
+
});
|
|
477
|
+
const body = (await response.json());
|
|
478
|
+
if (!response.ok || typeof body.token !== "string" || !body.token.startsWith("ssfs_")) {
|
|
479
|
+
return NextResponse.json({ error: "identity_link_failed" }, { status: 403 });
|
|
480
|
+
}
|
|
481
|
+
const completed = NextResponse.redirect(new URL(safeReturnTo(typeof pending.returnTo === "string" ? pending.returnTo : "/"), request.url), 303);
|
|
482
|
+
setSession(completed, request, body.token);
|
|
483
|
+
completed.cookies.set(pendingName, "", attributes(secure, 0));
|
|
484
|
+
return completed;
|
|
485
|
+
}
|
|
486
|
+
catch {
|
|
487
|
+
return NextResponse.json({ error: "identity_proof_required" }, { status: 403 });
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return {
|
|
491
|
+
...base,
|
|
492
|
+
authHandlers: () => {
|
|
493
|
+
const handlers = base.authHandlers();
|
|
494
|
+
if (!options.browserAuth || !options.neon)
|
|
495
|
+
return handlers;
|
|
496
|
+
const guard = (handler) => async (request, context) => {
|
|
497
|
+
const failure = await configurationFailure(request);
|
|
498
|
+
if (failure)
|
|
499
|
+
return failure;
|
|
500
|
+
return handler(request, context);
|
|
501
|
+
};
|
|
502
|
+
return {
|
|
503
|
+
GET: guard(handlers.GET),
|
|
504
|
+
POST: guard(handlers.POST),
|
|
505
|
+
PUT: guard(handlers.PUT),
|
|
506
|
+
DELETE: guard(handlers.DELETE),
|
|
507
|
+
PATCH: guard(handlers.PATCH),
|
|
508
|
+
};
|
|
509
|
+
},
|
|
510
|
+
handlers: () => ({
|
|
511
|
+
GET: async (request) => protectResponse(await GET(request)),
|
|
512
|
+
POST: async (request) => protectResponse(await POST(request)),
|
|
513
|
+
}),
|
|
514
|
+
getUser,
|
|
515
|
+
requireUser: async (settings) => {
|
|
516
|
+
const user = await getUser(settings);
|
|
517
|
+
if (!user) {
|
|
518
|
+
redirect("/sign-in");
|
|
519
|
+
}
|
|
520
|
+
return user;
|
|
521
|
+
},
|
|
522
|
+
can: async (permission, settings) => {
|
|
523
|
+
const user = await getUser(settings);
|
|
524
|
+
return !!user && isAllowed(permission, user);
|
|
525
|
+
},
|
|
526
|
+
getAccessToken: async () => null,
|
|
527
|
+
ontology: createOntologyClient(ontologyTransport),
|
|
528
|
+
oauth: createAgentAuthorizations(ontologyTransport),
|
|
529
|
+
agent: createAgentIdentities(ontologyTransport),
|
|
530
|
+
signOut: async (settings) => {
|
|
531
|
+
await options.channelSession?.signOut();
|
|
532
|
+
await endFederationSession();
|
|
533
|
+
const store = await cookies();
|
|
534
|
+
for (const secure of [true, false]) {
|
|
535
|
+
store.set(cookieName(config.workspaceId, secure), "", attributes(secure, 0));
|
|
536
|
+
}
|
|
537
|
+
if (options.neon) {
|
|
538
|
+
return base.signOut(settings);
|
|
539
|
+
}
|
|
540
|
+
redirect(safeReturnTo(settings?.redirectTo ?? "/sign-in"));
|
|
541
|
+
},
|
|
542
|
+
proxy: (settings) => withUnframeablePages(async (request) => {
|
|
543
|
+
const forwardedOrPublic = [
|
|
544
|
+
PREFIX,
|
|
545
|
+
"/sign-in",
|
|
546
|
+
"/api/auth",
|
|
547
|
+
"/mcp",
|
|
548
|
+
"/api/ontology",
|
|
549
|
+
"/_next",
|
|
550
|
+
"/.well-known",
|
|
551
|
+
...(settings?.publicPaths ?? []).map(normalizePublicPath),
|
|
552
|
+
].some((path) => matchesPathPrefix(request.nextUrl.pathname, path));
|
|
553
|
+
const channelResponse = forwardedOrPublic ? undefined : await channel.proxy(request);
|
|
554
|
+
if (channelResponse) {
|
|
555
|
+
return channelResponse;
|
|
556
|
+
}
|
|
557
|
+
const { secure } = cookiePolicyFor(new URL(request.url), config.workspaceId);
|
|
558
|
+
const credential = request.cookies.get(cookieName(config.workspaceId, secure))?.value;
|
|
559
|
+
if (matchesPathPrefix(request.nextUrl.pathname, PREFIX)) {
|
|
560
|
+
return NextResponse.next();
|
|
561
|
+
}
|
|
562
|
+
if (options.browserAuth && !forwardedOrPublic) {
|
|
563
|
+
const failure = await configurationFailure(request);
|
|
564
|
+
if (failure)
|
|
565
|
+
return failure;
|
|
566
|
+
}
|
|
567
|
+
if (credential && !forwardedOrPublic) {
|
|
568
|
+
const origin = await ontologyOrigin(options.ontologyUrl ?? request.nextUrl.origin);
|
|
569
|
+
const response = await options.fetch(`${origin}/api/ontology/me`, {
|
|
570
|
+
headers: { authorization: `Bearer ${credential}` },
|
|
571
|
+
cache: "no-store",
|
|
572
|
+
redirect: "error",
|
|
573
|
+
signal: AbortSignal.timeout(10000),
|
|
574
|
+
});
|
|
575
|
+
if (response.ok) {
|
|
576
|
+
await userFromResponse(response);
|
|
577
|
+
return NextResponse.next();
|
|
578
|
+
}
|
|
579
|
+
if (response.status !== 401 && response.status !== 403) {
|
|
580
|
+
return NextResponse.json({ error: "session_unavailable" }, { status: 503, headers: { "cache-control": "no-store", "retry-after": "5" } });
|
|
581
|
+
}
|
|
582
|
+
const denied = isApiRequest(request)
|
|
583
|
+
? unauthenticatedResponse()
|
|
584
|
+
: signInPage(request, "session_ended");
|
|
585
|
+
denied.cookies.set(cookieName(config.workspaceId, secure), "", attributes(secure, 0));
|
|
586
|
+
return denied;
|
|
587
|
+
}
|
|
588
|
+
if (options.browserAuth && !forwardedOrPublic) {
|
|
589
|
+
if (!options.neon) {
|
|
590
|
+
if (isApiRequest(request) || request.method !== "GET")
|
|
591
|
+
return unauthenticatedResponse();
|
|
592
|
+
const browser = options.browserAuth.browser;
|
|
593
|
+
const destination = request.headers.get("sec-fetch-dest");
|
|
594
|
+
// Embedded channel launches must reach the local sign-in response so the
|
|
595
|
+
// Telegram wrapper can exchange its assertion without opening central auth.
|
|
596
|
+
const topLevel = destination === null || destination === "document";
|
|
597
|
+
if (browser.auto_redirect && browser.methods.length === 1 && topLevel) {
|
|
598
|
+
const destination = new URL(`${PREFIX}/sign-in`, request.url);
|
|
599
|
+
destination.searchParams.set("returnTo", safeReturnTo(`${request.nextUrl.pathname}${request.nextUrl.search}`));
|
|
600
|
+
return NextResponse.redirect(destination);
|
|
601
|
+
}
|
|
602
|
+
return signInPage(request);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
return base.proxy(settings)(request);
|
|
606
|
+
}, channelAppFraming(config.channels)),
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
function protectResponse(response) {
|
|
610
|
+
response.headers.set("cache-control", "no-store");
|
|
611
|
+
response.headers.set("referrer-policy", "no-referrer");
|
|
612
|
+
return response;
|
|
613
|
+
}
|