@segmentstream-cloud/app 0.2.0 → 0.3.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 +192 -87
- package/dist/app.d.ts +35 -8
- package/dist/app.js +73 -8
- package/dist/config.d.ts +17 -1
- package/dist/config.js +29 -7
- package/dist/cookies.d.ts +16 -7
- package/dist/cookies.js +36 -47
- package/dist/data.d.ts +6 -18
- package/dist/data.js +17 -36
- package/dist/errors.d.ts +36 -0
- package/dist/errors.js +66 -0
- package/dist/handlers.js +43 -19
- package/dist/index.d.ts +18 -8
- package/dist/index.js +11 -6
- package/dist/me.d.ts +24 -0
- package/dist/me.js +33 -0
- package/dist/neon.d.ts +92 -0
- package/dist/neon.js +414 -0
- package/dist/ontology-transport.d.ts +23 -0
- package/dist/ontology-transport.js +95 -0
- package/dist/ontology.d.ts +217 -0
- package/dist/ontology.js +141 -0
- package/dist/permissions.d.ts +17 -0
- package/dist/permissions.js +33 -0
- package/dist/proxy.d.ts +2 -0
- package/dist/proxy.js +29 -13
- package/dist/return-to.d.ts +2 -0
- package/dist/return-to.js +24 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +35 -5
- package/dist/sign-in.d.ts +5 -1
- package/dist/sign-in.js +17 -4
- package/dist/tokens.d.ts +22 -3
- package/dist/ui/components.d.ts +27 -0
- package/dist/ui/components.js +63 -0
- package/dist/ui/hooks.d.ts +19 -0
- package/dist/ui/hooks.js +56 -0
- package/dist/ui/index.d.ts +10 -0
- package/dist/ui/index.js +8 -0
- package/dist/ui/navigation.d.ts +5 -0
- package/dist/ui/navigation.js +8 -0
- package/dist/ui/sign-in.d.ts +19 -0
- package/dist/ui/sign-in.js +198 -0
- package/dist/ui/store.d.ts +61 -0
- package/dist/ui/store.js +155 -0
- package/dist/ui/styles.css +249 -0
- package/package.json +24 -7
package/dist/neon.d.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import type { NextRequest } from "next/server";
|
|
3
|
+
import type { NeonAuthEnvironment, ResolvedConfig } from "./config.js";
|
|
4
|
+
import type { OntologyTransport } from "./ontology.js";
|
|
5
|
+
import type { ProxyOptions, SegmentStreamProxy } from "./proxy.js";
|
|
6
|
+
import type { Fetch } from "./sessions.js";
|
|
7
|
+
import type { AppUser } from "./tokens.js";
|
|
8
|
+
/** The app's sign-in page, which renders `<SignIn />` from `@segmentstream-cloud/app/ui`. */
|
|
9
|
+
export declare const SIGN_IN_PAGE = "/sign-in";
|
|
10
|
+
/** Where `app/src/app/api/auth/[...path]/route.ts` serves the Neon Auth API. */
|
|
11
|
+
export declare const AUTH_API_PATH = "/api/auth";
|
|
12
|
+
/**
|
|
13
|
+
* Paths every visitor may open: the Neon Auth API, the sign-in page, the health check, and the
|
|
14
|
+
* Next.js assets and metadata files the sign-in page needs.
|
|
15
|
+
*/
|
|
16
|
+
export declare const OPEN_PATHS: ReadonlyArray<string>;
|
|
17
|
+
interface NeonError {
|
|
18
|
+
readonly status?: number;
|
|
19
|
+
readonly code?: string;
|
|
20
|
+
readonly message?: string;
|
|
21
|
+
}
|
|
22
|
+
interface NeonResult<T> {
|
|
23
|
+
readonly data: T | null;
|
|
24
|
+
readonly error: NeonError | null;
|
|
25
|
+
}
|
|
26
|
+
interface NeonSessionData {
|
|
27
|
+
readonly session: {
|
|
28
|
+
readonly id: string;
|
|
29
|
+
} | null;
|
|
30
|
+
readonly user: {
|
|
31
|
+
readonly id: string;
|
|
32
|
+
readonly email?: string | null;
|
|
33
|
+
readonly name?: string | null;
|
|
34
|
+
} | null;
|
|
35
|
+
}
|
|
36
|
+
export type AuthRouteHandler = (request: Request, context: {
|
|
37
|
+
params: Promise<{
|
|
38
|
+
path: string[];
|
|
39
|
+
}>;
|
|
40
|
+
}) => Promise<Response>;
|
|
41
|
+
/** The Neon Auth API for `app/src/app/api/auth/[...path]/route.ts`. */
|
|
42
|
+
export interface AuthRouteHandlers {
|
|
43
|
+
readonly GET: AuthRouteHandler;
|
|
44
|
+
readonly POST: AuthRouteHandler;
|
|
45
|
+
readonly PUT: AuthRouteHandler;
|
|
46
|
+
readonly DELETE: AuthRouteHandler;
|
|
47
|
+
readonly PATCH: AuthRouteHandler;
|
|
48
|
+
}
|
|
49
|
+
/** The part of `createNeonAuth(...)` from `@neondatabase/auth/next/server` the SDK uses. */
|
|
50
|
+
export interface NeonAuthServer {
|
|
51
|
+
getSession(): Promise<NeonResult<NeonSessionData>>;
|
|
52
|
+
token(): Promise<NeonResult<{
|
|
53
|
+
readonly token?: string;
|
|
54
|
+
}>>;
|
|
55
|
+
signOut(): Promise<NeonResult<unknown>>;
|
|
56
|
+
handler(): AuthRouteHandlers;
|
|
57
|
+
middleware(config?: {
|
|
58
|
+
loginUrl?: string;
|
|
59
|
+
}): (request: NextRequest) => Promise<NextResponse>;
|
|
60
|
+
}
|
|
61
|
+
export type CreateNeonAuth = (config: {
|
|
62
|
+
readonly baseUrl: string;
|
|
63
|
+
readonly cookies: {
|
|
64
|
+
readonly secret: string;
|
|
65
|
+
readonly sameSite: "lax";
|
|
66
|
+
};
|
|
67
|
+
}) => NeonAuthServer;
|
|
68
|
+
export interface SignOutOptions {
|
|
69
|
+
/** Where to send the browser afterwards. Defaults to the sign-in page. */
|
|
70
|
+
readonly redirectTo?: string;
|
|
71
|
+
}
|
|
72
|
+
export interface NeonSessionDependencies {
|
|
73
|
+
readonly config: ResolvedConfig;
|
|
74
|
+
readonly settings: NeonAuthEnvironment;
|
|
75
|
+
readonly createNeonAuth: CreateNeonAuth;
|
|
76
|
+
readonly fetch: Fetch;
|
|
77
|
+
readonly now: () => number;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Neon Auth (Managed Better Auth) for the app's users: sessions in cookies through
|
|
81
|
+
* `@neondatabase/auth`, and roles and permissions from the ontology's `GET /api/ontology/me`.
|
|
82
|
+
*/
|
|
83
|
+
export declare function createNeonSession(dependencies: NeonSessionDependencies): {
|
|
84
|
+
proxy: (options?: ProxyOptions) => SegmentStreamProxy;
|
|
85
|
+
authHandlers: () => AuthRouteHandlers;
|
|
86
|
+
getUser: () => Promise<AppUser | null>;
|
|
87
|
+
requireUser: () => Promise<AppUser>;
|
|
88
|
+
can: (permission: string) => Promise<boolean>;
|
|
89
|
+
signOut: (options?: SignOutOptions) => Promise<never>;
|
|
90
|
+
ontologyTransport: OntologyTransport;
|
|
91
|
+
};
|
|
92
|
+
export {};
|
package/dist/neon.js
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import { NEON_AUTH_SESSION_COOKIE_NAME, NEON_AUTH_SESSION_DATA_COOKIE_NAME, parseSetCookies, } from "@neondatabase/auth/server";
|
|
2
|
+
import { decodeJwt } from "jose";
|
|
3
|
+
import { cookies } from "next/headers";
|
|
4
|
+
import { redirect } from "next/navigation";
|
|
5
|
+
import { NextResponse } from "next/server";
|
|
6
|
+
import { replaceRequestCookies, safeReturnTo } from "./cookies.js";
|
|
7
|
+
import { NOT_INVITED, SegmentStreamApiError, apiErrorFrom, readJson } from "./errors.js";
|
|
8
|
+
import { parseMe } from "./me.js";
|
|
9
|
+
import { createOntologyTransport } from "./ontology-transport.js";
|
|
10
|
+
import { isAllowed } from "./permissions.js";
|
|
11
|
+
import { matchesPathPrefix, normalizePublicPath } from "./proxy.js";
|
|
12
|
+
import { isApiRequest, unauthenticatedResponse } from "./sign-in.js";
|
|
13
|
+
/** The app's sign-in page, which renders `<SignIn />` from `@segmentstream-cloud/app/ui`. */
|
|
14
|
+
export const SIGN_IN_PAGE = "/sign-in";
|
|
15
|
+
/** Where `app/src/app/api/auth/[...path]/route.ts` serves the Neon Auth API. */
|
|
16
|
+
export const AUTH_API_PATH = "/api/auth";
|
|
17
|
+
/**
|
|
18
|
+
* Paths every visitor may open: the Neon Auth API, the sign-in page, the health check, and the
|
|
19
|
+
* Next.js assets and metadata files the sign-in page needs.
|
|
20
|
+
*/
|
|
21
|
+
export const OPEN_PATHS = [
|
|
22
|
+
AUTH_API_PATH,
|
|
23
|
+
SIGN_IN_PAGE,
|
|
24
|
+
"/api/health",
|
|
25
|
+
"/_next",
|
|
26
|
+
"/favicon.ico",
|
|
27
|
+
"/robots.txt",
|
|
28
|
+
"/sitemap.xml",
|
|
29
|
+
"/manifest.webmanifest",
|
|
30
|
+
];
|
|
31
|
+
/** How long a user's roles and permissions are reused before `/api/ontology/me` is asked again. */
|
|
32
|
+
const USER_TTL_MS = 60_000;
|
|
33
|
+
/** Neon Auth JWTs live 15 minutes; a cached one is replaced a minute before it expires. */
|
|
34
|
+
const TOKEN_EARLY_REFRESH_MS = 60_000;
|
|
35
|
+
const CACHE_LIMIT = 1000;
|
|
36
|
+
/** Entries that expire, at most {@link CACHE_LIMIT} of them; the oldest go first. */
|
|
37
|
+
class ExpiringCache {
|
|
38
|
+
now;
|
|
39
|
+
#entries = new Map();
|
|
40
|
+
constructor(now) {
|
|
41
|
+
this.now = now;
|
|
42
|
+
}
|
|
43
|
+
get(key) {
|
|
44
|
+
const entry = this.#entries.get(key);
|
|
45
|
+
if (entry === undefined) {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
if (entry.expiresAt <= this.now()) {
|
|
49
|
+
this.#entries.delete(key);
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
return entry.value;
|
|
53
|
+
}
|
|
54
|
+
set(key, value, expiresAt) {
|
|
55
|
+
this.#entries.delete(key);
|
|
56
|
+
this.#entries.set(key, { value, expiresAt });
|
|
57
|
+
for (const oldest of this.#entries.keys()) {
|
|
58
|
+
if (this.#entries.size <= CACHE_LIMIT) {
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
this.#entries.delete(oldest);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
delete(key) {
|
|
65
|
+
this.#entries.delete(key);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Neon Auth (Managed Better Auth) for the app's users: sessions in cookies through
|
|
70
|
+
* `@neondatabase/auth`, and roles and permissions from the ontology's `GET /api/ontology/me`.
|
|
71
|
+
*/
|
|
72
|
+
export function createNeonSession(dependencies) {
|
|
73
|
+
const { config, settings, now } = dependencies;
|
|
74
|
+
const tokens = new ExpiringCache(now);
|
|
75
|
+
const users = new ExpiringCache(now);
|
|
76
|
+
const loadingUsers = new Map();
|
|
77
|
+
let server;
|
|
78
|
+
let routeHandlers;
|
|
79
|
+
let middleware;
|
|
80
|
+
/** Created on first use, so that builds without the cookie secret still succeed. */
|
|
81
|
+
function neonAuth() {
|
|
82
|
+
if (server !== undefined) {
|
|
83
|
+
return server;
|
|
84
|
+
}
|
|
85
|
+
const secret = settings.cookieSecret;
|
|
86
|
+
if (secret === undefined || secret.length < 32) {
|
|
87
|
+
throw new Error("SEGMENTSTREAM_APP_AUTH_COOKIE_SECRET must hold at least 32 characters when NEON_AUTH_BASE_URL is set. " +
|
|
88
|
+
"SegmentStream sets both on deploy; for local development copy them into app/.env.local.");
|
|
89
|
+
}
|
|
90
|
+
server = dependencies.createNeonAuth({
|
|
91
|
+
baseUrl: settings.baseUrl,
|
|
92
|
+
// Lax keeps the session on links followed from email and other sites.
|
|
93
|
+
cookies: { secret, sameSite: "lax" },
|
|
94
|
+
});
|
|
95
|
+
return server;
|
|
96
|
+
}
|
|
97
|
+
async function identity() {
|
|
98
|
+
// Reading cookies first marks the page dynamic, so prerendering at build time stops here
|
|
99
|
+
// before any configuration is needed. Thrown errors pass through untouched: Next.js signals
|
|
100
|
+
// dynamic rendering with them.
|
|
101
|
+
await cookies();
|
|
102
|
+
const result = await neonAuth().getSession();
|
|
103
|
+
if (result.error !== null) {
|
|
104
|
+
if (result.error.status === 401) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
throw authUnavailable(result.error);
|
|
108
|
+
}
|
|
109
|
+
const session = result.data?.session;
|
|
110
|
+
const user = result.data?.user;
|
|
111
|
+
if (session === null || session === undefined || user === null || user === undefined) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
sessionId: session.id,
|
|
116
|
+
userId: user.id,
|
|
117
|
+
email: user.email ?? undefined,
|
|
118
|
+
name: user.name ?? undefined,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/** The session's JWT for the ontology service, reused until shortly before it expires. */
|
|
122
|
+
async function sessionToken(session) {
|
|
123
|
+
const cached = tokens.get(session.sessionId);
|
|
124
|
+
if (cached !== undefined) {
|
|
125
|
+
return cached;
|
|
126
|
+
}
|
|
127
|
+
const result = await neonAuth().token();
|
|
128
|
+
if (result.error !== null) {
|
|
129
|
+
if (result.error.status === 401) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
throw authUnavailable(result.error);
|
|
133
|
+
}
|
|
134
|
+
const token = result.data?.token;
|
|
135
|
+
if (typeof token !== "string" || token === "") {
|
|
136
|
+
throw authUnavailable(new Error("Neon Auth returned no token."));
|
|
137
|
+
}
|
|
138
|
+
tokens.set(session.sessionId, token, tokenRefreshAt(token, now()));
|
|
139
|
+
return token;
|
|
140
|
+
}
|
|
141
|
+
function transportFor(session) {
|
|
142
|
+
return createOntologyTransport({
|
|
143
|
+
fetch: dependencies.fetch,
|
|
144
|
+
ontologyUrl: settings.ontologyUrl,
|
|
145
|
+
credentials: {
|
|
146
|
+
token: () => sessionToken(session),
|
|
147
|
+
rejected: () => tokens.delete(session.sessionId),
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
/** Ontology requests as the signed-in user. */
|
|
152
|
+
const ontologyTransport = {
|
|
153
|
+
async fetch(path, init) {
|
|
154
|
+
const session = await identity();
|
|
155
|
+
if (session === null) {
|
|
156
|
+
throw new SegmentStreamApiError({
|
|
157
|
+
code: "app_session_missing",
|
|
158
|
+
status: 401,
|
|
159
|
+
detail: "No signed-in user for this request; call the ontology from Server Components, Route Handlers, or Server Functions behind the SegmentStream proxy.",
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return transportFor(session).fetch(path, init);
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
async function currentUser(session) {
|
|
166
|
+
const cached = users.get(session.sessionId);
|
|
167
|
+
if (cached !== undefined) {
|
|
168
|
+
return cached;
|
|
169
|
+
}
|
|
170
|
+
const pending = loadingUsers.get(session.sessionId);
|
|
171
|
+
if (pending !== undefined) {
|
|
172
|
+
return pending;
|
|
173
|
+
}
|
|
174
|
+
const loading = (async () => {
|
|
175
|
+
const response = await transportFor(session).fetch("/me", {
|
|
176
|
+
method: "GET",
|
|
177
|
+
headers: { accept: "application/json" },
|
|
178
|
+
});
|
|
179
|
+
const payload = await readJson(response);
|
|
180
|
+
if (!response.ok) {
|
|
181
|
+
throw apiErrorFrom(response.status, payload, "The ontology service rejected the user.");
|
|
182
|
+
}
|
|
183
|
+
const user = parseMe(payload);
|
|
184
|
+
if (user === null) {
|
|
185
|
+
throw new SegmentStreamApiError({
|
|
186
|
+
code: "ontology_invalid_response",
|
|
187
|
+
status: 502,
|
|
188
|
+
detail: "GET /api/ontology/me did not answer { user, roles, permissions }.",
|
|
189
|
+
retryable: true,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
users.set(session.sessionId, user, now() + USER_TTL_MS);
|
|
193
|
+
return user;
|
|
194
|
+
})().finally(() => loadingUsers.delete(session.sessionId));
|
|
195
|
+
loadingUsers.set(session.sessionId, loading);
|
|
196
|
+
return loading;
|
|
197
|
+
}
|
|
198
|
+
function appUser(session, user) {
|
|
199
|
+
const email = user.email ?? session.email;
|
|
200
|
+
const name = user.name ?? session.name;
|
|
201
|
+
return {
|
|
202
|
+
id: user.id,
|
|
203
|
+
...(email === undefined ? {} : { email }),
|
|
204
|
+
...(name === undefined ? {} : { name }),
|
|
205
|
+
organizationId: config.organizationId,
|
|
206
|
+
workspaceId: config.workspaceId,
|
|
207
|
+
roles: user.roles,
|
|
208
|
+
permissions: user.permissions,
|
|
209
|
+
kind: user.kind,
|
|
210
|
+
active: user.active,
|
|
211
|
+
authUserId: session.userId,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
async function getUser() {
|
|
215
|
+
const session = await identity();
|
|
216
|
+
if (session === null) {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
return appUser(session, await currentUser(session));
|
|
220
|
+
}
|
|
221
|
+
async function requireUser() {
|
|
222
|
+
const session = await identity();
|
|
223
|
+
if (session === null) {
|
|
224
|
+
redirect(SIGN_IN_PAGE);
|
|
225
|
+
}
|
|
226
|
+
let user;
|
|
227
|
+
let notInvited = false;
|
|
228
|
+
try {
|
|
229
|
+
user = await currentUser(session);
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
if (!(error instanceof SegmentStreamApiError) || error.code !== NOT_INVITED) {
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
notInvited = true;
|
|
236
|
+
}
|
|
237
|
+
if (notInvited || user === undefined) {
|
|
238
|
+
// The sign-in page explains it and offers to sign out; redirect() must not run inside try.
|
|
239
|
+
redirect(`${SIGN_IN_PAGE}?error=${NOT_INVITED}`);
|
|
240
|
+
}
|
|
241
|
+
return appUser(session, user);
|
|
242
|
+
}
|
|
243
|
+
async function can(permission) {
|
|
244
|
+
let user;
|
|
245
|
+
try {
|
|
246
|
+
user = await getUser();
|
|
247
|
+
}
|
|
248
|
+
catch (error) {
|
|
249
|
+
if (error instanceof SegmentStreamApiError && error.code === NOT_INVITED) {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
254
|
+
return user !== null && isAllowed(permission, user);
|
|
255
|
+
}
|
|
256
|
+
async function signOut(options = {}) {
|
|
257
|
+
const session = await identity().catch(() => null);
|
|
258
|
+
if (session !== null) {
|
|
259
|
+
tokens.delete(session.sessionId);
|
|
260
|
+
users.delete(session.sessionId);
|
|
261
|
+
}
|
|
262
|
+
try {
|
|
263
|
+
await neonAuth().signOut();
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
// Ending the session upstream is best effort; the cookies below end it in this browser.
|
|
267
|
+
}
|
|
268
|
+
const cookieStore = await cookies();
|
|
269
|
+
for (const name of [NEON_AUTH_SESSION_COOKIE_NAME, NEON_AUTH_SESSION_DATA_COOKIE_NAME]) {
|
|
270
|
+
cookieStore.set(name, "", {
|
|
271
|
+
httpOnly: true,
|
|
272
|
+
secure: true,
|
|
273
|
+
sameSite: "lax",
|
|
274
|
+
path: "/",
|
|
275
|
+
maxAge: 0,
|
|
276
|
+
expires: new Date(0),
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
redirect(safeReturnTo(options.redirectTo ?? SIGN_IN_PAGE));
|
|
280
|
+
}
|
|
281
|
+
function authHandlers() {
|
|
282
|
+
const handlers = () => (routeHandlers ??= neonAuth().handler());
|
|
283
|
+
return {
|
|
284
|
+
GET: (request, context) => handlers().GET(request, context),
|
|
285
|
+
POST: (request, context) => handlers().POST(request, context),
|
|
286
|
+
PUT: (request, context) => handlers().PUT(request, context),
|
|
287
|
+
DELETE: (request, context) => handlers().DELETE(request, context),
|
|
288
|
+
PATCH: (request, context) => handlers().PATCH(request, context),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
function proxy(options = {}) {
|
|
292
|
+
const publicPaths = [...OPEN_PATHS, ...(options.publicPaths ?? [])].map(normalizePublicPath);
|
|
293
|
+
return async function neonProxy(request) {
|
|
294
|
+
const { pathname, search } = request.nextUrl;
|
|
295
|
+
if (settings.ontologyUrl !== undefined && matchesPathPrefix(pathname, "/api/ontology")) {
|
|
296
|
+
// Local development: the service runs elsewhere; deployed, /api/ontology never reaches the app.
|
|
297
|
+
return NextResponse.rewrite(new URL(`${pathname}${search}`, settings.ontologyUrl));
|
|
298
|
+
}
|
|
299
|
+
if (publicPaths.some((path) => matchesPathPrefix(pathname, path))) {
|
|
300
|
+
return NextResponse.next();
|
|
301
|
+
}
|
|
302
|
+
middleware ??= neonAuth().middleware({ loginUrl: SIGN_IN_PAGE });
|
|
303
|
+
const response = await middleware(request);
|
|
304
|
+
if (!isSignInRedirect(request, response)) {
|
|
305
|
+
return forwardSessionCookies(request, response);
|
|
306
|
+
}
|
|
307
|
+
const replacement = isApiRequest(request)
|
|
308
|
+
? unauthenticatedResponse()
|
|
309
|
+
: signInPageRedirect(request);
|
|
310
|
+
// Neon Auth clears a stale session cache cookie on the way to sign-in.
|
|
311
|
+
for (const cookie of response.headers.getSetCookie()) {
|
|
312
|
+
replacement.headers.append("set-cookie", cookie);
|
|
313
|
+
}
|
|
314
|
+
return replacement;
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
return {
|
|
318
|
+
proxy,
|
|
319
|
+
authHandlers,
|
|
320
|
+
getUser,
|
|
321
|
+
requireUser,
|
|
322
|
+
can,
|
|
323
|
+
signOut,
|
|
324
|
+
ontologyTransport,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
/** Neon Auth's middleware sends visitors without a session to the configured sign-in page. */
|
|
328
|
+
function isSignInRedirect(request, response) {
|
|
329
|
+
const location = response.headers.get("location");
|
|
330
|
+
if (response.status < 300 || response.status >= 400 || location === null) {
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
try {
|
|
334
|
+
const target = new URL(location, request.nextUrl.origin);
|
|
335
|
+
return target.origin === request.nextUrl.origin && target.pathname === SIGN_IN_PAGE;
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
const MIDDLEWARE_REQUEST_HEADER = "x-middleware-request-";
|
|
342
|
+
/**
|
|
343
|
+
* Neon Auth's middleware refreshes the session cookies on its response only, so the page this
|
|
344
|
+
* request renders would still read the old ones and ask Neon Auth again, which cannot set cookies
|
|
345
|
+
* from a Server Component. The refreshed cookies go into the request too: through
|
|
346
|
+
* `NextResponse.cookies`, which Next.js merges into `cookies()`, and the `Cookie` header, which
|
|
347
|
+
* Route Handlers read.
|
|
348
|
+
*/
|
|
349
|
+
function forwardSessionCookies(request, response) {
|
|
350
|
+
const setCookies = response.headers.getSetCookie();
|
|
351
|
+
if (response.headers.get("x-middleware-next") !== "1" || setCookies.length === 0) {
|
|
352
|
+
return response;
|
|
353
|
+
}
|
|
354
|
+
const refreshed = setCookies.flatMap((header) => parseSetCookies(header));
|
|
355
|
+
const expired = (cookie) => cookie.value === "" ||
|
|
356
|
+
(cookie.maxAge !== undefined && cookie.maxAge <= 0) ||
|
|
357
|
+
(cookie.expires !== undefined && cookie.expires.getTime() <= Date.now());
|
|
358
|
+
const requestHeaders = new Headers(request.headers);
|
|
359
|
+
// Keep the request headers Neon Auth's middleware added.
|
|
360
|
+
for (const [name, value] of response.headers) {
|
|
361
|
+
if (name.startsWith(MIDDLEWARE_REQUEST_HEADER)) {
|
|
362
|
+
requestHeaders.set(name.slice(MIDDLEWARE_REQUEST_HEADER.length), value);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
const removed = new Set(refreshed.filter(expired).map((cookie) => cookie.name));
|
|
366
|
+
const live = refreshed.filter((cookie) => !expired(cookie));
|
|
367
|
+
const cookieHeader = replaceRequestCookies(request.headers.get("cookie"), Object.fromEntries(live.map((cookie) => [cookie.name, cookie.value])))
|
|
368
|
+
.split("; ")
|
|
369
|
+
.filter((pair) => pair !== "" && !removed.has(pair.split("=", 1)[0] ?? ""))
|
|
370
|
+
.join("; ");
|
|
371
|
+
requestHeaders.set("cookie", cookieHeader);
|
|
372
|
+
const forwarded = NextResponse.next({ request: { headers: requestHeaders } });
|
|
373
|
+
for (const { name, value, ...options } of refreshed) {
|
|
374
|
+
forwarded.cookies.set(name, value, options);
|
|
375
|
+
}
|
|
376
|
+
return forwarded;
|
|
377
|
+
}
|
|
378
|
+
/** Page navigations go to the sign-in page and come back to where they started. */
|
|
379
|
+
function signInPageRedirect(request) {
|
|
380
|
+
const url = new URL(SIGN_IN_PAGE, request.nextUrl.origin);
|
|
381
|
+
const returnTo = safeReturnTo(`${request.nextUrl.pathname}${request.nextUrl.search}`);
|
|
382
|
+
if (returnTo !== "/" && returnTo.length <= 1024) {
|
|
383
|
+
url.searchParams.set("returnTo", returnTo);
|
|
384
|
+
}
|
|
385
|
+
const response = NextResponse.redirect(url, 307);
|
|
386
|
+
response.headers.set("cache-control", "no-store");
|
|
387
|
+
return response;
|
|
388
|
+
}
|
|
389
|
+
function tokenRefreshAt(token, now) {
|
|
390
|
+
try {
|
|
391
|
+
const { exp } = decodeJwt(token);
|
|
392
|
+
if (typeof exp === "number") {
|
|
393
|
+
return Math.max(now, exp * 1000 - TOKEN_EARLY_REFRESH_MS);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
// Not a JWT the SDK can read; the service decides whether it is valid.
|
|
398
|
+
}
|
|
399
|
+
return now;
|
|
400
|
+
}
|
|
401
|
+
function authUnavailable(cause) {
|
|
402
|
+
const code = typeof cause === "object" && cause !== null && "code" in cause && typeof cause.code === "string"
|
|
403
|
+
? cause.code
|
|
404
|
+
: undefined;
|
|
405
|
+
return new SegmentStreamApiError({
|
|
406
|
+
code: "auth_unavailable",
|
|
407
|
+
status: 503,
|
|
408
|
+
detail: code === undefined
|
|
409
|
+
? "Neon Auth could not be reached. Try again in a moment."
|
|
410
|
+
: `Neon Auth could not be reached (${code}). Try again in a moment.`,
|
|
411
|
+
retryable: true,
|
|
412
|
+
cause,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { OntologyTransport } from "./ontology.js";
|
|
2
|
+
import type { Fetch } from "./sessions.js";
|
|
3
|
+
/** Every ontology route lives under this path, beside the app on the same origin. */
|
|
4
|
+
export declare const ONTOLOGY_BASE_PATH = "/api/ontology";
|
|
5
|
+
/** The caller's bearer token for the ontology service. */
|
|
6
|
+
export interface OntologyCredentials {
|
|
7
|
+
/** The token for this request, or `null` when nobody is signed in. */
|
|
8
|
+
readonly token: () => Promise<string | null>;
|
|
9
|
+
/** Forgets a token the service rejected, so `token` returns a fresh one. */
|
|
10
|
+
readonly rejected?: (token: string) => void;
|
|
11
|
+
}
|
|
12
|
+
export interface OntologyTransportOptions {
|
|
13
|
+
readonly fetch: Fetch;
|
|
14
|
+
/** `SEGMENTSTREAM_ONTOLOGY_URL`: where the service runs when it is not beside the app. */
|
|
15
|
+
readonly ontologyUrl: string | undefined;
|
|
16
|
+
readonly credentials: OntologyCredentials;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Server-side requests to the ontology service with the signed-in user's token. Deployed, the
|
|
20
|
+
* service shares the app's origin; `SEGMENTSTREAM_ONTOLOGY_URL` points elsewhere, for example at
|
|
21
|
+
* `http://localhost:3100` or the deployed app during local development.
|
|
22
|
+
*/
|
|
23
|
+
export declare function createOntologyTransport(options: OntologyTransportOptions): OntologyTransport;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { headers } from "next/headers";
|
|
2
|
+
import { trimTrailingSlashes } from "./config.js";
|
|
3
|
+
import { isLoopbackHostname } from "./cookies.js";
|
|
4
|
+
import { SegmentStreamApiError } from "./errors.js";
|
|
5
|
+
/** Every ontology route lives under this path, beside the app on the same origin. */
|
|
6
|
+
export const ONTOLOGY_BASE_PATH = "/api/ontology";
|
|
7
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
8
|
+
/**
|
|
9
|
+
* Server-side requests to the ontology service with the signed-in user's token. Deployed, the
|
|
10
|
+
* service shares the app's origin; `SEGMENTSTREAM_ONTOLOGY_URL` points elsewhere, for example at
|
|
11
|
+
* `http://localhost:3100` or the deployed app during local development.
|
|
12
|
+
*/
|
|
13
|
+
export function createOntologyTransport(options) {
|
|
14
|
+
async function send(url, token, init) {
|
|
15
|
+
const requestHeaders = new Headers(init.headers);
|
|
16
|
+
const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
|
|
17
|
+
requestHeaders.set("authorization", `Bearer ${token}`);
|
|
18
|
+
try {
|
|
19
|
+
return await options.fetch(url, {
|
|
20
|
+
...init,
|
|
21
|
+
headers: requestHeaders,
|
|
22
|
+
cache: "no-store",
|
|
23
|
+
redirect: "error",
|
|
24
|
+
signal: init.signal ? AbortSignal.any([init.signal, timeout]) : timeout,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
if (init.signal?.aborted) {
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
throw new SegmentStreamApiError({
|
|
32
|
+
code: "ontology_unreachable",
|
|
33
|
+
status: 503,
|
|
34
|
+
detail: `The ontology service at ${new URL(url).origin} could not be reached.`,
|
|
35
|
+
retryable: true,
|
|
36
|
+
cause: error,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
async fetch(path, init) {
|
|
42
|
+
const token = await options.credentials.token();
|
|
43
|
+
if (token === null) {
|
|
44
|
+
throw new SegmentStreamApiError({
|
|
45
|
+
code: "app_session_missing",
|
|
46
|
+
status: 401,
|
|
47
|
+
detail: "No signed-in user for this request; call the ontology from Server Components, Route Handlers, or Server Functions behind the SegmentStream proxy.",
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
const url = `${await ontologyOrigin(options.ontologyUrl)}${ONTOLOGY_BASE_PATH}${path}`;
|
|
51
|
+
const response = await send(url, token, init);
|
|
52
|
+
if (response.status !== 401 || options.credentials.rejected === undefined) {
|
|
53
|
+
return response;
|
|
54
|
+
}
|
|
55
|
+
// A cached token the service no longer accepts: retry once with a fresh one.
|
|
56
|
+
options.credentials.rejected(token);
|
|
57
|
+
const fresh = await options.credentials.token();
|
|
58
|
+
if (fresh === null || fresh === token) {
|
|
59
|
+
return response;
|
|
60
|
+
}
|
|
61
|
+
await response.body?.cancel();
|
|
62
|
+
return send(url, fresh, init);
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
async function ontologyOrigin(configured) {
|
|
67
|
+
if (configured !== undefined) {
|
|
68
|
+
return trimTrailingSlashes(configured);
|
|
69
|
+
}
|
|
70
|
+
// Vercel sets Host to the domain the request arrived on, which routes /api/ontology to the service.
|
|
71
|
+
const headerStore = await headers();
|
|
72
|
+
const host = headerStore.get("host");
|
|
73
|
+
if (host === null || host === "") {
|
|
74
|
+
throw new SegmentStreamApiError({
|
|
75
|
+
code: "ontology_url_unknown",
|
|
76
|
+
status: 500,
|
|
77
|
+
detail: "The request has no Host header to reach the ontology service; set SEGMENTSTREAM_ONTOLOGY_URL.",
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
const forwarded = headerStore.get("x-forwarded-proto")?.split(",")[0]?.trim();
|
|
81
|
+
const protocol = forwarded === "http" || forwarded === "https"
|
|
82
|
+
? forwarded
|
|
83
|
+
: isLoopbackHostname(hostnameOf(host))
|
|
84
|
+
? "http"
|
|
85
|
+
: "https";
|
|
86
|
+
return `${protocol}://${host}`;
|
|
87
|
+
}
|
|
88
|
+
function hostnameOf(host) {
|
|
89
|
+
try {
|
|
90
|
+
return new URL(`http://${host}`).hostname;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return "";
|
|
94
|
+
}
|
|
95
|
+
}
|