@segmentstream-cloud/app 0.2.0 → 0.3.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/README.md +198 -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 +38 -0
- package/dist/errors.js +68 -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 +471 -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 +20 -0
- package/dist/ui/sign-in.js +200 -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/me.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** The ontology service's account of the caller: `GET /api/ontology/me`. */
|
|
2
|
+
export const ME_PATH = "/api/ontology/me";
|
|
3
|
+
/**
|
|
4
|
+
* Reads `{ user: { id, name, email, kind, active }, roles, permissions }` tolerantly: unknown
|
|
5
|
+
* fields are ignored and only what the SDK presents is checked. `null` when it is not that shape.
|
|
6
|
+
*/
|
|
7
|
+
export function parseMe(payload) {
|
|
8
|
+
if (typeof payload !== "object" || payload === null) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
const { user, roles, permissions } = payload;
|
|
12
|
+
if (typeof user !== "object" || user === null) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
const { id, name, email, kind, active } = user;
|
|
16
|
+
if (typeof id !== "string" || id === "") {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
id,
|
|
21
|
+
name: typeof name === "string" ? name : null,
|
|
22
|
+
email: typeof email === "string" ? email : null,
|
|
23
|
+
kind: typeof kind === "string" ? kind : "member",
|
|
24
|
+
active: typeof active === "boolean" ? active : true,
|
|
25
|
+
roles: strings(roles),
|
|
26
|
+
permissions: strings(permissions),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function strings(value) {
|
|
30
|
+
return Array.isArray(value)
|
|
31
|
+
? value.filter((item) => typeof item === "string")
|
|
32
|
+
: [];
|
|
33
|
+
}
|
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,471 @@
|
|
|
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, SIGN_IN_LINK_INVALID, 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
|
+
/**
|
|
32
|
+
* Better Auth answers a magic link that names a callback with a redirect to that path on Neon
|
|
33
|
+
* Auth's own origin; Neon's proxy follows it and loses the session cookie. Without them it answers
|
|
34
|
+
* JSON with the cookies, and the app's auth route redirects itself.
|
|
35
|
+
*/
|
|
36
|
+
const MAGIC_LINK_CALLBACK_PARAMETERS = ["callbackURL", "errorCallbackURL", "newUserCallbackURL"];
|
|
37
|
+
/** How long a user's roles and permissions are reused before `/api/ontology/me` is asked again. */
|
|
38
|
+
const USER_TTL_MS = 60_000;
|
|
39
|
+
/** Neon Auth JWTs live 15 minutes; a cached one is replaced a minute before it expires. */
|
|
40
|
+
const TOKEN_EARLY_REFRESH_MS = 60_000;
|
|
41
|
+
const CACHE_LIMIT = 1000;
|
|
42
|
+
/** Entries that expire, at most {@link CACHE_LIMIT} of them; the oldest go first. */
|
|
43
|
+
class ExpiringCache {
|
|
44
|
+
now;
|
|
45
|
+
#entries = new Map();
|
|
46
|
+
constructor(now) {
|
|
47
|
+
this.now = now;
|
|
48
|
+
}
|
|
49
|
+
get(key) {
|
|
50
|
+
const entry = this.#entries.get(key);
|
|
51
|
+
if (entry === undefined) {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
if (entry.expiresAt <= this.now()) {
|
|
55
|
+
this.#entries.delete(key);
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
return entry.value;
|
|
59
|
+
}
|
|
60
|
+
set(key, value, expiresAt) {
|
|
61
|
+
this.#entries.delete(key);
|
|
62
|
+
this.#entries.set(key, { value, expiresAt });
|
|
63
|
+
for (const oldest of this.#entries.keys()) {
|
|
64
|
+
if (this.#entries.size <= CACHE_LIMIT) {
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
this.#entries.delete(oldest);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
delete(key) {
|
|
71
|
+
this.#entries.delete(key);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Neon Auth (Managed Better Auth) for the app's users: sessions in cookies through
|
|
76
|
+
* `@neondatabase/auth`, and roles and permissions from the ontology's `GET /api/ontology/me`.
|
|
77
|
+
*/
|
|
78
|
+
export function createNeonSession(dependencies) {
|
|
79
|
+
const { config, settings, now } = dependencies;
|
|
80
|
+
const tokens = new ExpiringCache(now);
|
|
81
|
+
const users = new ExpiringCache(now);
|
|
82
|
+
const loadingUsers = new Map();
|
|
83
|
+
let server;
|
|
84
|
+
let routeHandlers;
|
|
85
|
+
let middleware;
|
|
86
|
+
/** Created on first use, so that builds without the cookie secret still succeed. */
|
|
87
|
+
function neonAuth() {
|
|
88
|
+
if (server !== undefined) {
|
|
89
|
+
return server;
|
|
90
|
+
}
|
|
91
|
+
const secret = settings.cookieSecret;
|
|
92
|
+
if (secret === undefined || secret.length < 32) {
|
|
93
|
+
throw new Error("SEGMENTSTREAM_APP_AUTH_COOKIE_SECRET must hold at least 32 characters when NEON_AUTH_BASE_URL is set. " +
|
|
94
|
+
"SegmentStream sets both on deploy; for local development copy them into app/.env.local.");
|
|
95
|
+
}
|
|
96
|
+
server = dependencies.createNeonAuth({
|
|
97
|
+
baseUrl: settings.baseUrl,
|
|
98
|
+
// Lax keeps the session on links followed from email and other sites.
|
|
99
|
+
cookies: { secret, sameSite: "lax" },
|
|
100
|
+
});
|
|
101
|
+
return server;
|
|
102
|
+
}
|
|
103
|
+
async function identity() {
|
|
104
|
+
// Reading cookies first marks the page dynamic, so prerendering at build time stops here
|
|
105
|
+
// before any configuration is needed. Thrown errors pass through untouched: Next.js signals
|
|
106
|
+
// dynamic rendering with them.
|
|
107
|
+
await cookies();
|
|
108
|
+
const result = await neonAuth().getSession();
|
|
109
|
+
if (result.error !== null) {
|
|
110
|
+
if (result.error.status === 401) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
throw authUnavailable(result.error);
|
|
114
|
+
}
|
|
115
|
+
const session = result.data?.session;
|
|
116
|
+
const user = result.data?.user;
|
|
117
|
+
if (session === null || session === undefined || user === null || user === undefined) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
sessionId: session.id,
|
|
122
|
+
userId: user.id,
|
|
123
|
+
email: user.email ?? undefined,
|
|
124
|
+
name: user.name ?? undefined,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/** The session's JWT for the ontology service, reused until shortly before it expires. */
|
|
128
|
+
async function sessionToken(session) {
|
|
129
|
+
const cached = tokens.get(session.sessionId);
|
|
130
|
+
if (cached !== undefined) {
|
|
131
|
+
return cached;
|
|
132
|
+
}
|
|
133
|
+
const result = await neonAuth().token();
|
|
134
|
+
if (result.error !== null) {
|
|
135
|
+
if (result.error.status === 401) {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
throw authUnavailable(result.error);
|
|
139
|
+
}
|
|
140
|
+
const token = result.data?.token;
|
|
141
|
+
if (typeof token !== "string" || token === "") {
|
|
142
|
+
throw authUnavailable(new Error("Neon Auth returned no token."));
|
|
143
|
+
}
|
|
144
|
+
tokens.set(session.sessionId, token, tokenRefreshAt(token, now()));
|
|
145
|
+
return token;
|
|
146
|
+
}
|
|
147
|
+
function transportFor(session) {
|
|
148
|
+
return createOntologyTransport({
|
|
149
|
+
fetch: dependencies.fetch,
|
|
150
|
+
ontologyUrl: settings.ontologyUrl,
|
|
151
|
+
credentials: {
|
|
152
|
+
token: () => sessionToken(session),
|
|
153
|
+
rejected: () => tokens.delete(session.sessionId),
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
/** Ontology requests as the signed-in user. */
|
|
158
|
+
const ontologyTransport = {
|
|
159
|
+
async fetch(path, init) {
|
|
160
|
+
const session = await identity();
|
|
161
|
+
if (session === null) {
|
|
162
|
+
throw new SegmentStreamApiError({
|
|
163
|
+
code: "app_session_missing",
|
|
164
|
+
status: 401,
|
|
165
|
+
detail: "No signed-in user for this request; call the ontology from Server Components, Route Handlers, or Server Functions behind the SegmentStream proxy.",
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
return transportFor(session).fetch(path, init);
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
async function currentUser(session) {
|
|
172
|
+
const cached = users.get(session.sessionId);
|
|
173
|
+
if (cached !== undefined) {
|
|
174
|
+
return cached;
|
|
175
|
+
}
|
|
176
|
+
const pending = loadingUsers.get(session.sessionId);
|
|
177
|
+
if (pending !== undefined) {
|
|
178
|
+
return pending;
|
|
179
|
+
}
|
|
180
|
+
const loading = (async () => {
|
|
181
|
+
const response = await transportFor(session).fetch("/me", {
|
|
182
|
+
method: "GET",
|
|
183
|
+
headers: { accept: "application/json" },
|
|
184
|
+
});
|
|
185
|
+
const payload = await readJson(response);
|
|
186
|
+
if (!response.ok) {
|
|
187
|
+
throw apiErrorFrom(response.status, payload, "The ontology service rejected the user.");
|
|
188
|
+
}
|
|
189
|
+
const user = parseMe(payload);
|
|
190
|
+
if (user === null) {
|
|
191
|
+
throw new SegmentStreamApiError({
|
|
192
|
+
code: "ontology_invalid_response",
|
|
193
|
+
status: 502,
|
|
194
|
+
detail: "GET /api/ontology/me did not answer { user, roles, permissions }.",
|
|
195
|
+
retryable: true,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
users.set(session.sessionId, user, now() + USER_TTL_MS);
|
|
199
|
+
return user;
|
|
200
|
+
})().finally(() => loadingUsers.delete(session.sessionId));
|
|
201
|
+
loadingUsers.set(session.sessionId, loading);
|
|
202
|
+
return loading;
|
|
203
|
+
}
|
|
204
|
+
function appUser(session, user) {
|
|
205
|
+
const email = user.email ?? session.email;
|
|
206
|
+
const name = user.name ?? session.name;
|
|
207
|
+
return {
|
|
208
|
+
id: user.id,
|
|
209
|
+
...(email === undefined ? {} : { email }),
|
|
210
|
+
...(name === undefined ? {} : { name }),
|
|
211
|
+
organizationId: config.organizationId,
|
|
212
|
+
workspaceId: config.workspaceId,
|
|
213
|
+
roles: user.roles,
|
|
214
|
+
permissions: user.permissions,
|
|
215
|
+
kind: user.kind,
|
|
216
|
+
active: user.active,
|
|
217
|
+
authUserId: session.userId,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
async function getUser() {
|
|
221
|
+
const session = await identity();
|
|
222
|
+
if (session === null) {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
return appUser(session, await currentUser(session));
|
|
226
|
+
}
|
|
227
|
+
async function requireUser() {
|
|
228
|
+
const session = await identity();
|
|
229
|
+
if (session === null) {
|
|
230
|
+
redirect(SIGN_IN_PAGE);
|
|
231
|
+
}
|
|
232
|
+
let user;
|
|
233
|
+
let notInvited = false;
|
|
234
|
+
try {
|
|
235
|
+
user = await currentUser(session);
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
if (!(error instanceof SegmentStreamApiError) || error.code !== NOT_INVITED) {
|
|
239
|
+
throw error;
|
|
240
|
+
}
|
|
241
|
+
notInvited = true;
|
|
242
|
+
}
|
|
243
|
+
if (notInvited || user === undefined) {
|
|
244
|
+
// The sign-in page explains it and offers to sign out; redirect() must not run inside try.
|
|
245
|
+
redirect(`${SIGN_IN_PAGE}?error=${NOT_INVITED}`);
|
|
246
|
+
}
|
|
247
|
+
return appUser(session, user);
|
|
248
|
+
}
|
|
249
|
+
async function can(permission) {
|
|
250
|
+
let user;
|
|
251
|
+
try {
|
|
252
|
+
user = await getUser();
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
if (error instanceof SegmentStreamApiError && error.code === NOT_INVITED) {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
throw error;
|
|
259
|
+
}
|
|
260
|
+
return user !== null && isAllowed(permission, user);
|
|
261
|
+
}
|
|
262
|
+
async function signOut(options = {}) {
|
|
263
|
+
const session = await identity().catch(() => null);
|
|
264
|
+
if (session !== null) {
|
|
265
|
+
tokens.delete(session.sessionId);
|
|
266
|
+
users.delete(session.sessionId);
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
await neonAuth().signOut();
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
// Ending the session upstream is best effort; the cookies below end it in this browser.
|
|
273
|
+
}
|
|
274
|
+
const cookieStore = await cookies();
|
|
275
|
+
for (const name of [NEON_AUTH_SESSION_COOKIE_NAME, NEON_AUTH_SESSION_DATA_COOKIE_NAME]) {
|
|
276
|
+
cookieStore.set(name, "", {
|
|
277
|
+
httpOnly: true,
|
|
278
|
+
secure: true,
|
|
279
|
+
sameSite: "lax",
|
|
280
|
+
path: "/",
|
|
281
|
+
maxAge: 0,
|
|
282
|
+
expires: new Date(0),
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
redirect(safeReturnTo(options.redirectTo ?? SIGN_IN_PAGE));
|
|
286
|
+
}
|
|
287
|
+
function authHandlers() {
|
|
288
|
+
const handlers = () => (routeHandlers ??= neonAuth().handler());
|
|
289
|
+
return {
|
|
290
|
+
GET: async (request, context) => {
|
|
291
|
+
const { path } = await context.params;
|
|
292
|
+
return path.length === 2 && path[0] === "magic-link" && path[1] === "verify"
|
|
293
|
+
? completeMagicLink(request, context, () => handlers().GET)
|
|
294
|
+
: handlers().GET(request, context);
|
|
295
|
+
},
|
|
296
|
+
POST: (request, context) => handlers().POST(request, context),
|
|
297
|
+
PUT: (request, context) => handlers().PUT(request, context),
|
|
298
|
+
DELETE: (request, context) => handlers().DELETE(request, context),
|
|
299
|
+
PATCH: (request, context) => handlers().PATCH(request, context),
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
function proxy(options = {}) {
|
|
303
|
+
const publicPaths = [...OPEN_PATHS, ...(options.publicPaths ?? [])].map(normalizePublicPath);
|
|
304
|
+
return async function neonProxy(request) {
|
|
305
|
+
const { pathname, search } = request.nextUrl;
|
|
306
|
+
if (settings.ontologyUrl !== undefined && matchesPathPrefix(pathname, "/api/ontology")) {
|
|
307
|
+
// Local development: the service runs elsewhere; deployed, /api/ontology never reaches the app.
|
|
308
|
+
return NextResponse.rewrite(new URL(`${pathname}${search}`, settings.ontologyUrl));
|
|
309
|
+
}
|
|
310
|
+
if (publicPaths.some((path) => matchesPathPrefix(pathname, path))) {
|
|
311
|
+
return NextResponse.next();
|
|
312
|
+
}
|
|
313
|
+
middleware ??= neonAuth().middleware({ loginUrl: SIGN_IN_PAGE });
|
|
314
|
+
const response = await middleware(request);
|
|
315
|
+
if (!isSignInRedirect(request, response)) {
|
|
316
|
+
return forwardSessionCookies(request, response);
|
|
317
|
+
}
|
|
318
|
+
const replacement = isApiRequest(request)
|
|
319
|
+
? unauthenticatedResponse()
|
|
320
|
+
: signInPageRedirect(request);
|
|
321
|
+
// Neon Auth clears a stale session cache cookie on the way to sign-in.
|
|
322
|
+
for (const cookie of response.headers.getSetCookie()) {
|
|
323
|
+
replacement.headers.append("set-cookie", cookie);
|
|
324
|
+
}
|
|
325
|
+
return replacement;
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
return {
|
|
329
|
+
proxy,
|
|
330
|
+
authHandlers,
|
|
331
|
+
getUser,
|
|
332
|
+
requireUser,
|
|
333
|
+
can,
|
|
334
|
+
signOut,
|
|
335
|
+
ontologyTransport,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
/** Neon Auth's middleware sends visitors without a session to the configured sign-in page. */
|
|
339
|
+
function isSignInRedirect(request, response) {
|
|
340
|
+
const location = response.headers.get("location");
|
|
341
|
+
if (response.status < 300 || response.status >= 400 || location === null) {
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
try {
|
|
345
|
+
const target = new URL(location, request.nextUrl.origin);
|
|
346
|
+
return target.origin === request.nextUrl.origin && target.pathname === SIGN_IN_PAGE;
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
const MIDDLEWARE_REQUEST_HEADER = "x-middleware-request-";
|
|
353
|
+
/**
|
|
354
|
+
* Neon Auth's middleware refreshes the session cookies on its response only, so the page this
|
|
355
|
+
* request renders would still read the old ones and ask Neon Auth again, which cannot set cookies
|
|
356
|
+
* from a Server Component. The refreshed cookies go into the request too: through
|
|
357
|
+
* `NextResponse.cookies`, which Next.js merges into `cookies()`, and the `Cookie` header, which
|
|
358
|
+
* Route Handlers read.
|
|
359
|
+
*/
|
|
360
|
+
function forwardSessionCookies(request, response) {
|
|
361
|
+
const setCookies = response.headers.getSetCookie();
|
|
362
|
+
if (response.headers.get("x-middleware-next") !== "1" || setCookies.length === 0) {
|
|
363
|
+
return response;
|
|
364
|
+
}
|
|
365
|
+
const refreshed = setCookies.flatMap((header) => parseSetCookies(header));
|
|
366
|
+
const expired = (cookie) => cookie.value === "" ||
|
|
367
|
+
(cookie.maxAge !== undefined && cookie.maxAge <= 0) ||
|
|
368
|
+
(cookie.expires !== undefined && cookie.expires.getTime() <= Date.now());
|
|
369
|
+
const requestHeaders = new Headers(request.headers);
|
|
370
|
+
// Keep the request headers Neon Auth's middleware added.
|
|
371
|
+
for (const [name, value] of response.headers) {
|
|
372
|
+
if (name.startsWith(MIDDLEWARE_REQUEST_HEADER)) {
|
|
373
|
+
requestHeaders.set(name.slice(MIDDLEWARE_REQUEST_HEADER.length), value);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
const removed = new Set(refreshed.filter(expired).map((cookie) => cookie.name));
|
|
377
|
+
const live = refreshed.filter((cookie) => !expired(cookie));
|
|
378
|
+
const cookieHeader = replaceRequestCookies(request.headers.get("cookie"), Object.fromEntries(live.map((cookie) => [cookie.name, cookie.value])))
|
|
379
|
+
.split("; ")
|
|
380
|
+
.filter((pair) => pair !== "" && !removed.has(pair.split("=", 1)[0] ?? ""))
|
|
381
|
+
.join("; ");
|
|
382
|
+
requestHeaders.set("cookie", cookieHeader);
|
|
383
|
+
const forwarded = NextResponse.next({ request: { headers: requestHeaders } });
|
|
384
|
+
for (const { name, value, ...options } of refreshed) {
|
|
385
|
+
forwarded.cookies.set(name, value, options);
|
|
386
|
+
}
|
|
387
|
+
return forwarded;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Signs in with an emailed magic link on the app's own domain: Neon Auth verifies the token
|
|
391
|
+
* without a callback and sets the session cookies, which go to the browser with a redirect to the
|
|
392
|
+
* link's `callbackURL` path. A link Neon Auth rejects leads to the sign-in page instead.
|
|
393
|
+
*/
|
|
394
|
+
async function completeMagicLink(request, context, verify) {
|
|
395
|
+
const url = new URL(request.url);
|
|
396
|
+
const destination = safeReturnTo(url.searchParams.get("callbackURL"));
|
|
397
|
+
const token = url.searchParams.get("token");
|
|
398
|
+
if (token === null || token === "") {
|
|
399
|
+
return invalidSignInLink(url.origin, destination);
|
|
400
|
+
}
|
|
401
|
+
for (const name of MAGIC_LINK_CALLBACK_PARAMETERS) {
|
|
402
|
+
url.searchParams.delete(name);
|
|
403
|
+
}
|
|
404
|
+
const handler = verify();
|
|
405
|
+
let response;
|
|
406
|
+
try {
|
|
407
|
+
response = await handler(new Request(url, request), context);
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
return invalidSignInLink(url.origin, destination);
|
|
411
|
+
}
|
|
412
|
+
// The body holds the session token; only the cookies go on to the browser.
|
|
413
|
+
void response.body?.cancel().catch(() => undefined);
|
|
414
|
+
if (!response.ok) {
|
|
415
|
+
return invalidSignInLink(url.origin, destination);
|
|
416
|
+
}
|
|
417
|
+
const redirect = NextResponse.redirect(new URL(destination, url.origin), 303);
|
|
418
|
+
for (const cookie of response.headers.getSetCookie()) {
|
|
419
|
+
redirect.headers.append("set-cookie", cookie);
|
|
420
|
+
}
|
|
421
|
+
redirect.headers.set("cache-control", "no-store");
|
|
422
|
+
return redirect;
|
|
423
|
+
}
|
|
424
|
+
/** An expired, used or unknown sign-in link: the sign-in page explains it and sends a code. */
|
|
425
|
+
function invalidSignInLink(origin, destination) {
|
|
426
|
+
const url = new URL(SIGN_IN_PAGE, origin);
|
|
427
|
+
url.searchParams.set("error", SIGN_IN_LINK_INVALID);
|
|
428
|
+
if (destination !== "/") {
|
|
429
|
+
url.searchParams.set("returnTo", destination);
|
|
430
|
+
}
|
|
431
|
+
const response = NextResponse.redirect(url, 303);
|
|
432
|
+
response.headers.set("cache-control", "no-store");
|
|
433
|
+
return response;
|
|
434
|
+
}
|
|
435
|
+
/** Page navigations go to the sign-in page and come back to where they started. */
|
|
436
|
+
function signInPageRedirect(request) {
|
|
437
|
+
const url = new URL(SIGN_IN_PAGE, request.nextUrl.origin);
|
|
438
|
+
const returnTo = safeReturnTo(`${request.nextUrl.pathname}${request.nextUrl.search}`);
|
|
439
|
+
if (returnTo !== "/" && returnTo.length <= 1024) {
|
|
440
|
+
url.searchParams.set("returnTo", returnTo);
|
|
441
|
+
}
|
|
442
|
+
const response = NextResponse.redirect(url, 307);
|
|
443
|
+
response.headers.set("cache-control", "no-store");
|
|
444
|
+
return response;
|
|
445
|
+
}
|
|
446
|
+
function tokenRefreshAt(token, now) {
|
|
447
|
+
try {
|
|
448
|
+
const { exp } = decodeJwt(token);
|
|
449
|
+
if (typeof exp === "number") {
|
|
450
|
+
return Math.max(now, exp * 1000 - TOKEN_EARLY_REFRESH_MS);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
// Not a JWT the SDK can read; the service decides whether it is valid.
|
|
455
|
+
}
|
|
456
|
+
return now;
|
|
457
|
+
}
|
|
458
|
+
function authUnavailable(cause) {
|
|
459
|
+
const code = typeof cause === "object" && cause !== null && "code" in cause && typeof cause.code === "string"
|
|
460
|
+
? cause.code
|
|
461
|
+
: undefined;
|
|
462
|
+
return new SegmentStreamApiError({
|
|
463
|
+
code: "auth_unavailable",
|
|
464
|
+
status: 503,
|
|
465
|
+
detail: code === undefined
|
|
466
|
+
? "Neon Auth could not be reached. Try again in a moment."
|
|
467
|
+
: `Neon Auth could not be reached (${code}). Try again in a moment.`,
|
|
468
|
+
retryable: true,
|
|
469
|
+
cause,
|
|
470
|
+
});
|
|
471
|
+
}
|
|
@@ -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;
|