@segmentstream-cloud/app 0.0.0-stage → 0.2.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/LICENSE +201 -0
- package/NOTICE +2 -0
- package/README.md +190 -2
- package/dist/app.d.ts +33 -0
- package/dist/app.js +35 -0
- package/dist/config.d.ts +18 -0
- package/dist/config.js +41 -0
- package/dist/context.d.ts +10 -0
- package/dist/context.js +1 -0
- package/dist/cookies.d.ts +47 -0
- package/dist/cookies.js +142 -0
- package/dist/data.d.ts +138 -0
- package/dist/data.js +225 -0
- package/dist/handlers.d.ts +8 -0
- package/dist/handlers.js +133 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +16 -0
- package/dist/parameters.d.ts +55 -0
- package/dist/parameters.js +118 -0
- package/dist/pkce.d.ts +8 -0
- package/dist/pkce.js +40 -0
- package/dist/proxy.d.ts +13 -0
- package/dist/proxy.js +91 -0
- package/dist/server.d.ts +8 -0
- package/dist/server.js +50 -0
- package/dist/sessions.d.ts +27 -0
- package/dist/sessions.js +72 -0
- package/dist/sign-in.d.ts +28 -0
- package/dist/sign-in.js +56 -0
- package/dist/tokens.d.ts +26 -0
- package/dist/tokens.js +70 -0
- package/package.json +55 -4
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export declare const PARAMETER_TYPES: readonly ["STRING", "INT64", "FLOAT64", "NUMERIC", "BIGNUMERIC", "BOOL", "DATE", "DATETIME", "TIMESTAMP"];
|
|
2
|
+
export type ParameterType = (typeof PARAMETER_TYPES)[number];
|
|
3
|
+
declare const TYPED: unique symbol;
|
|
4
|
+
/** A value with an explicit warehouse type, created with the `sql` helpers. */
|
|
5
|
+
export interface TypedParameter {
|
|
6
|
+
readonly [TYPED]: true;
|
|
7
|
+
readonly type: ParameterType;
|
|
8
|
+
readonly value?: string;
|
|
9
|
+
readonly values?: readonly string[];
|
|
10
|
+
}
|
|
11
|
+
/** A value bound to a named `@parameter`; never spliced into the SQL text. */
|
|
12
|
+
export type QueryParameterValue = string | number | bigint | boolean | Date | TypedParameter | ReadonlyArray<string | number | bigint | boolean | Date>;
|
|
13
|
+
export type QueryParameters = Readonly<Record<string, QueryParameterValue>>;
|
|
14
|
+
/** The wire form the SegmentStream API accepts. */
|
|
15
|
+
export type EncodedParameter = {
|
|
16
|
+
readonly name: string;
|
|
17
|
+
readonly type: ParameterType;
|
|
18
|
+
readonly value: string;
|
|
19
|
+
} | {
|
|
20
|
+
readonly name: string;
|
|
21
|
+
readonly type: ParameterType;
|
|
22
|
+
readonly values: readonly string[];
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Explicit parameter types for values JavaScript cannot express unambiguously.
|
|
26
|
+
*
|
|
27
|
+
* ```ts
|
|
28
|
+
* await segmentstream.warehouse.query(
|
|
29
|
+
* "SELECT * FROM gold.orders WHERE order_date >= @from AND amount > @minimum",
|
|
30
|
+
* { params: { from: sql.date("2026-09-01"), minimum: sql.numeric("10.50") } },
|
|
31
|
+
* );
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export declare const sql: {
|
|
35
|
+
readonly string: (value: string) => TypedParameter;
|
|
36
|
+
readonly int64: (value: number | bigint | string) => TypedParameter;
|
|
37
|
+
readonly float64: (value: number) => TypedParameter;
|
|
38
|
+
readonly numeric: (value: string | number) => TypedParameter;
|
|
39
|
+
readonly bignumeric: (value: string | number) => TypedParameter;
|
|
40
|
+
readonly bool: (value: boolean) => TypedParameter;
|
|
41
|
+
/** A calendar date, `YYYY-MM-DD`; a `Date` uses its UTC day. */
|
|
42
|
+
readonly date: (value: string | Date) => TypedParameter;
|
|
43
|
+
/** A civil date and time without a time zone, `YYYY-MM-DDTHH:MM:SS[.ffffff]`. */
|
|
44
|
+
readonly datetime: (value: string) => TypedParameter;
|
|
45
|
+
readonly timestamp: (value: string | Date) => TypedParameter;
|
|
46
|
+
/** An array of one element type; needed for empty arrays and non-default element types. */
|
|
47
|
+
readonly array: (type: ParameterType, values: ReadonlyArray<string | number | bigint | boolean | Date>) => {
|
|
48
|
+
[TYPED]: true;
|
|
49
|
+
type: "STRING" | "INT64" | "FLOAT64" | "NUMERIC" | "BIGNUMERIC" | "BOOL" | "DATE" | "DATETIME" | "TIMESTAMP";
|
|
50
|
+
values: string[];
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
/** Infers warehouse types for named parameters and validates their names. */
|
|
54
|
+
export declare function encodeParameters(parameters: QueryParameters | undefined): EncodedParameter[];
|
|
55
|
+
export {};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
export const PARAMETER_TYPES = [
|
|
2
|
+
"STRING",
|
|
3
|
+
"INT64",
|
|
4
|
+
"FLOAT64",
|
|
5
|
+
"NUMERIC",
|
|
6
|
+
"BIGNUMERIC",
|
|
7
|
+
"BOOL",
|
|
8
|
+
"DATE",
|
|
9
|
+
"DATETIME",
|
|
10
|
+
"TIMESTAMP",
|
|
11
|
+
];
|
|
12
|
+
const TYPED = Symbol.for("segmentstream.app.queryParameter");
|
|
13
|
+
const NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
|
|
14
|
+
function typed(type, value) {
|
|
15
|
+
return { [TYPED]: true, type, value };
|
|
16
|
+
}
|
|
17
|
+
function isoDate(value, name) {
|
|
18
|
+
if (value instanceof Date) {
|
|
19
|
+
if (Number.isNaN(value.getTime())) {
|
|
20
|
+
throw new TypeError(`sql.${name}() received an invalid Date.`);
|
|
21
|
+
}
|
|
22
|
+
return value.toISOString().slice(0, 10);
|
|
23
|
+
}
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
function timestamp(value) {
|
|
27
|
+
if (value instanceof Date) {
|
|
28
|
+
if (Number.isNaN(value.getTime())) {
|
|
29
|
+
throw new TypeError("sql.timestamp() received an invalid Date.");
|
|
30
|
+
}
|
|
31
|
+
return value.toISOString();
|
|
32
|
+
}
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Explicit parameter types for values JavaScript cannot express unambiguously.
|
|
37
|
+
*
|
|
38
|
+
* ```ts
|
|
39
|
+
* await segmentstream.warehouse.query(
|
|
40
|
+
* "SELECT * FROM gold.orders WHERE order_date >= @from AND amount > @minimum",
|
|
41
|
+
* { params: { from: sql.date("2026-09-01"), minimum: sql.numeric("10.50") } },
|
|
42
|
+
* );
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export const sql = {
|
|
46
|
+
string: (value) => typed("STRING", value),
|
|
47
|
+
int64: (value) => typed("INT64", String(value)),
|
|
48
|
+
float64: (value) => typed("FLOAT64", String(value)),
|
|
49
|
+
numeric: (value) => typed("NUMERIC", String(value)),
|
|
50
|
+
bignumeric: (value) => typed("BIGNUMERIC", String(value)),
|
|
51
|
+
bool: (value) => typed("BOOL", String(value)),
|
|
52
|
+
/** A calendar date, `YYYY-MM-DD`; a `Date` uses its UTC day. */
|
|
53
|
+
date: (value) => typed("DATE", isoDate(value, "date")),
|
|
54
|
+
/** A civil date and time without a time zone, `YYYY-MM-DDTHH:MM:SS[.ffffff]`. */
|
|
55
|
+
datetime: (value) => typed("DATETIME", value),
|
|
56
|
+
timestamp: (value) => typed("TIMESTAMP", timestamp(value)),
|
|
57
|
+
/** An array of one element type; needed for empty arrays and non-default element types. */
|
|
58
|
+
array: (type, values) => ({
|
|
59
|
+
[TYPED]: true,
|
|
60
|
+
type,
|
|
61
|
+
values: values.map((value) => scalar(value, "array element").value),
|
|
62
|
+
}),
|
|
63
|
+
};
|
|
64
|
+
function isTyped(value) {
|
|
65
|
+
return typeof value === "object" && value !== null && TYPED in value;
|
|
66
|
+
}
|
|
67
|
+
function scalar(value, name) {
|
|
68
|
+
switch (typeof value) {
|
|
69
|
+
case "string":
|
|
70
|
+
return { type: "STRING", value };
|
|
71
|
+
case "boolean":
|
|
72
|
+
return { type: "BOOL", value: String(value) };
|
|
73
|
+
case "bigint":
|
|
74
|
+
return { type: "INT64", value: String(value) };
|
|
75
|
+
case "number":
|
|
76
|
+
return Number.isSafeInteger(value)
|
|
77
|
+
? { type: "INT64", value: String(value) }
|
|
78
|
+
: { type: "FLOAT64", value: String(value) };
|
|
79
|
+
default:
|
|
80
|
+
if (value instanceof Date) {
|
|
81
|
+
return { type: "TIMESTAMP", value: timestamp(value) };
|
|
82
|
+
}
|
|
83
|
+
throw new TypeError(`Query parameter "${name}" must be a string, number, bigint, boolean, Date, or sql.* value; ` +
|
|
84
|
+
"use SQL NULL in the query text instead of a null parameter.");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** Infers warehouse types for named parameters and validates their names. */
|
|
88
|
+
export function encodeParameters(parameters) {
|
|
89
|
+
if (parameters === undefined) {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
return Object.entries(parameters).map(([name, value]) => {
|
|
93
|
+
if (!NAME.test(name)) {
|
|
94
|
+
throw new TypeError(`Query parameter name "${name}" must start with a letter or underscore and contain only letters, digits, and underscores.`);
|
|
95
|
+
}
|
|
96
|
+
if (isTyped(value)) {
|
|
97
|
+
return value.values === undefined
|
|
98
|
+
? { name, type: value.type, value: value.value ?? "" }
|
|
99
|
+
: { name, type: value.type, values: value.values };
|
|
100
|
+
}
|
|
101
|
+
if (Array.isArray(value)) {
|
|
102
|
+
if (value.length === 0) {
|
|
103
|
+
throw new TypeError(`Query parameter "${name}" is an empty array; use sql.array(type, []) to declare its element type.`);
|
|
104
|
+
}
|
|
105
|
+
const items = value.map((item) => scalar(item, name));
|
|
106
|
+
const type = items.every((item) => item.type === items[0]?.type)
|
|
107
|
+
? items[0].type
|
|
108
|
+
: items.every((item) => item.type === "INT64" || item.type === "FLOAT64")
|
|
109
|
+
? "FLOAT64"
|
|
110
|
+
: undefined;
|
|
111
|
+
if (type === undefined) {
|
|
112
|
+
throw new TypeError(`Query parameter "${name}" mixes element types; use sql.array(type, values).`);
|
|
113
|
+
}
|
|
114
|
+
return { name, type, values: items.map((item) => item.value) };
|
|
115
|
+
}
|
|
116
|
+
return { name, ...scalar(value, name) };
|
|
117
|
+
});
|
|
118
|
+
}
|
package/dist/pkce.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare function base64UrlEncode(bytes: Uint8Array): string;
|
|
2
|
+
export declare function base64UrlDecode(value: string): Uint8Array;
|
|
3
|
+
/** A URL-safe random value carrying `byteLength` bytes of entropy. */
|
|
4
|
+
export declare function randomToken(byteLength?: number): string;
|
|
5
|
+
/** The RFC 7636 S256 code challenge for a code verifier. */
|
|
6
|
+
export declare function createCodeChallenge(codeVerifier: string): Promise<string>;
|
|
7
|
+
/** Compares two strings without short-circuiting on the first differing character. */
|
|
8
|
+
export declare function constantTimeEqual(left: string, right: string): boolean;
|
package/dist/pkce.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Web Crypto only, so the same code runs in the Node.js and Edge runtimes.
|
|
2
|
+
export function base64UrlEncode(bytes) {
|
|
3
|
+
let binary = "";
|
|
4
|
+
for (const byte of bytes) {
|
|
5
|
+
binary += String.fromCharCode(byte);
|
|
6
|
+
}
|
|
7
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
8
|
+
}
|
|
9
|
+
export function base64UrlDecode(value) {
|
|
10
|
+
const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
11
|
+
const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
|
|
12
|
+
const binary = atob(padded);
|
|
13
|
+
const bytes = new Uint8Array(binary.length);
|
|
14
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
15
|
+
bytes[index] = binary.charCodeAt(index);
|
|
16
|
+
}
|
|
17
|
+
return bytes;
|
|
18
|
+
}
|
|
19
|
+
/** A URL-safe random value carrying `byteLength` bytes of entropy. */
|
|
20
|
+
export function randomToken(byteLength = 32) {
|
|
21
|
+
const bytes = new Uint8Array(byteLength);
|
|
22
|
+
crypto.getRandomValues(bytes);
|
|
23
|
+
return base64UrlEncode(bytes);
|
|
24
|
+
}
|
|
25
|
+
/** The RFC 7636 S256 code challenge for a code verifier. */
|
|
26
|
+
export async function createCodeChallenge(codeVerifier) {
|
|
27
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(codeVerifier));
|
|
28
|
+
return base64UrlEncode(new Uint8Array(digest));
|
|
29
|
+
}
|
|
30
|
+
/** Compares two strings without short-circuiting on the first differing character. */
|
|
31
|
+
export function constantTimeEqual(left, right) {
|
|
32
|
+
if (left.length !== right.length) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
let difference = 0;
|
|
36
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
37
|
+
difference |= left.charCodeAt(index) ^ right.charCodeAt(index);
|
|
38
|
+
}
|
|
39
|
+
return difference === 0;
|
|
40
|
+
}
|
package/dist/proxy.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import type { NextRequest } from "next/server";
|
|
3
|
+
import type { AppContext } from "./context.js";
|
|
4
|
+
export interface ProxyOptions {
|
|
5
|
+
/**
|
|
6
|
+
* Paths that do not require a signed-in user. Each entry matches itself and everything
|
|
7
|
+
* below it on path-segment boundaries: `/health` matches `/health` and `/health/db`,
|
|
8
|
+
* not `/healthy`.
|
|
9
|
+
*/
|
|
10
|
+
readonly publicPaths?: readonly string[];
|
|
11
|
+
}
|
|
12
|
+
export type SegmentStreamProxy = (request: NextRequest) => Promise<NextResponse>;
|
|
13
|
+
export declare function createProxy(context: AppContext, options?: ProxyOptions): SegmentStreamProxy;
|
package/dist/proxy.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { trimTrailingSlashes } from "./config.js";
|
|
3
|
+
import { clearSessionCookies, cookiePolicyFor, replaceRequestCookies, safeReturnTo, setSessionCookies, } from "./cookies.js";
|
|
4
|
+
import { SDK_ROUTE_PREFIX, isApiRequest, signInRedirect, unauthenticatedResponse, } from "./sign-in.js";
|
|
5
|
+
export function createProxy(context, options = {}) {
|
|
6
|
+
const publicPaths = (options.publicPaths ?? []).map(normalizePublicPath);
|
|
7
|
+
return async function proxy(request) {
|
|
8
|
+
const pathname = request.nextUrl.pathname;
|
|
9
|
+
if (matchesPathPrefix(pathname, SDK_ROUTE_PREFIX) ||
|
|
10
|
+
publicPaths.some((path) => matchesPathPrefix(pathname, path))) {
|
|
11
|
+
return NextResponse.next();
|
|
12
|
+
}
|
|
13
|
+
const policy = cookiePolicyFor(request.nextUrl);
|
|
14
|
+
const accessToken = request.cookies.get(policy.names.session)?.value;
|
|
15
|
+
if (accessToken !== undefined && accessToken !== "") {
|
|
16
|
+
const verification = await context.verifyToken(accessToken);
|
|
17
|
+
if (verification.status === "valid") {
|
|
18
|
+
return NextResponse.next();
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const refreshToken = request.cookies.get(policy.names.refresh)?.value;
|
|
22
|
+
if (refreshToken === undefined || refreshToken === "") {
|
|
23
|
+
return startSignIn(context, request, accessToken !== undefined);
|
|
24
|
+
}
|
|
25
|
+
const exchange = await context.requestToken({ grantType: "refresh_token", refreshToken });
|
|
26
|
+
if (exchange.status === "rejected") {
|
|
27
|
+
return startSignIn(context, request, true);
|
|
28
|
+
}
|
|
29
|
+
if (exchange.status === "unavailable") {
|
|
30
|
+
return sessionUnavailable(context, request);
|
|
31
|
+
}
|
|
32
|
+
const verification = await context.verifyToken(exchange.tokens.accessToken);
|
|
33
|
+
if (verification.status === "valid") {
|
|
34
|
+
return continueWithSession(request, policy, exchange.tokens);
|
|
35
|
+
}
|
|
36
|
+
if (verification.status === "unavailable") {
|
|
37
|
+
// The refresh token was rotated; keep the new one even though this request cannot proceed.
|
|
38
|
+
const response = await sessionUnavailable(context, request);
|
|
39
|
+
setSessionCookies(response, policy, exchange.tokens);
|
|
40
|
+
return response;
|
|
41
|
+
}
|
|
42
|
+
return startSignIn(context, request, true);
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* SegmentStream is unreachable. API requests get a retryable 503; pages restart sign-in,
|
|
47
|
+
* which ends on the callback's error page rather than looping. Session cookies are kept.
|
|
48
|
+
*/
|
|
49
|
+
async function sessionUnavailable(context, request) {
|
|
50
|
+
if (isApiRequest(request)) {
|
|
51
|
+
return NextResponse.json({ error: "session_refresh_unavailable" }, { status: 503, headers: { "cache-control": "no-store", "retry-after": "5" } });
|
|
52
|
+
}
|
|
53
|
+
return startSignIn(context, request, false);
|
|
54
|
+
}
|
|
55
|
+
async function startSignIn(context, request, clearSession) {
|
|
56
|
+
if (isApiRequest(request)) {
|
|
57
|
+
const response = unauthenticatedResponse();
|
|
58
|
+
if (clearSession) {
|
|
59
|
+
clearSessionCookies(response, cookiePolicyFor(request.nextUrl));
|
|
60
|
+
}
|
|
61
|
+
return response;
|
|
62
|
+
}
|
|
63
|
+
const returnTo = safeReturnTo(`${request.nextUrl.pathname}${request.nextUrl.search}`);
|
|
64
|
+
return signInRedirect({ config: context.config, request, returnTo, clearSession });
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Lets the current request render with the refreshed session. Response cookies set
|
|
68
|
+
* through `NextResponse.cookies` are merged into `cookies()` by Next.js, and the
|
|
69
|
+
* rewritten `Cookie` request header covers Route Handlers that read `request.cookies`.
|
|
70
|
+
*/
|
|
71
|
+
function continueWithSession(request, policy, tokens) {
|
|
72
|
+
const requestHeaders = new Headers(request.headers);
|
|
73
|
+
requestHeaders.set("cookie", replaceRequestCookies(request.headers.get("cookie"), {
|
|
74
|
+
[policy.names.session]: tokens.accessToken,
|
|
75
|
+
[policy.names.refresh]: tokens.refreshToken,
|
|
76
|
+
}));
|
|
77
|
+
const response = NextResponse.next({ request: { headers: requestHeaders } });
|
|
78
|
+
setSessionCookies(response, policy, tokens);
|
|
79
|
+
return response;
|
|
80
|
+
}
|
|
81
|
+
function normalizePublicPath(path) {
|
|
82
|
+
const withLeadingSlash = path.startsWith("/") ? path : `/${path}`;
|
|
83
|
+
const trimmed = trimTrailingSlashes(withLeadingSlash);
|
|
84
|
+
return trimmed === "" ? "/" : trimmed;
|
|
85
|
+
}
|
|
86
|
+
function matchesPathPrefix(pathname, prefix) {
|
|
87
|
+
if (prefix === "/") {
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
return pathname === prefix || pathname.startsWith(`${prefix}/`);
|
|
91
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { AppContext } from "./context.js";
|
|
2
|
+
import type { AppUser } from "./tokens.js";
|
|
3
|
+
export interface ServerSession {
|
|
4
|
+
readonly getUser: () => Promise<AppUser | null>;
|
|
5
|
+
readonly requireUser: () => Promise<AppUser>;
|
|
6
|
+
readonly getAccessToken: () => Promise<string | null>;
|
|
7
|
+
}
|
|
8
|
+
export declare function createServerSession(context: AppContext): ServerSession;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { cookies, headers } from "next/headers";
|
|
2
|
+
import { redirect } from "next/navigation";
|
|
3
|
+
import { sessionCookieCandidates } from "./cookies.js";
|
|
4
|
+
import { SIGN_IN_PATH } from "./sign-in.js";
|
|
5
|
+
export function createServerSession(context) {
|
|
6
|
+
async function verifiedSession() {
|
|
7
|
+
const cookieStore = await cookies();
|
|
8
|
+
const headerStore = await headers();
|
|
9
|
+
const hostname = hostnameOf(headerStore.get("host"));
|
|
10
|
+
for (const name of sessionCookieCandidates(hostname)) {
|
|
11
|
+
const token = cookieStore.get(name)?.value;
|
|
12
|
+
if (token === undefined || token === "") {
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
const verification = await context.verifyToken(token);
|
|
16
|
+
if (verification.status === "valid") {
|
|
17
|
+
return verification;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
async function getUser() {
|
|
23
|
+
const session = await verifiedSession();
|
|
24
|
+
return session?.user ?? null;
|
|
25
|
+
}
|
|
26
|
+
async function requireUser() {
|
|
27
|
+
const user = await getUser();
|
|
28
|
+
if (user === null) {
|
|
29
|
+
// `unauthorized()` needs the experimental `authInterrupts` flag, so restart sign-in instead.
|
|
30
|
+
redirect(SIGN_IN_PATH);
|
|
31
|
+
}
|
|
32
|
+
return user;
|
|
33
|
+
}
|
|
34
|
+
async function getAccessToken() {
|
|
35
|
+
const session = await verifiedSession();
|
|
36
|
+
return session?.token ?? null;
|
|
37
|
+
}
|
|
38
|
+
return { getUser, requireUser, getAccessToken };
|
|
39
|
+
}
|
|
40
|
+
function hostnameOf(host) {
|
|
41
|
+
if (host === null || host === "") {
|
|
42
|
+
return "";
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
return new URL(`http://${host}`).hostname;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return "";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ResolvedConfig } from "./config.js";
|
|
2
|
+
import type { SessionTokens } from "./cookies.js";
|
|
3
|
+
export type Fetch = typeof fetch;
|
|
4
|
+
export type TokenGrant = {
|
|
5
|
+
readonly grantType: "authorization_code";
|
|
6
|
+
readonly code: string;
|
|
7
|
+
readonly codeVerifier: string;
|
|
8
|
+
readonly redirectUri: string;
|
|
9
|
+
} | {
|
|
10
|
+
readonly grantType: "refresh_token";
|
|
11
|
+
readonly refreshToken: string;
|
|
12
|
+
};
|
|
13
|
+
export type TokenExchange = {
|
|
14
|
+
readonly status: "issued";
|
|
15
|
+
readonly tokens: SessionTokens;
|
|
16
|
+
} | {
|
|
17
|
+
readonly status: "rejected";
|
|
18
|
+
readonly httpStatus: number;
|
|
19
|
+
readonly code: string | null;
|
|
20
|
+
} | {
|
|
21
|
+
readonly status: "unavailable";
|
|
22
|
+
};
|
|
23
|
+
/** Calls the SegmentStream app-session endpoints from the app's server. */
|
|
24
|
+
export declare function createSessionClient(config: ResolvedConfig, fetchImplementation: Fetch): {
|
|
25
|
+
requestToken: (grant: TokenGrant) => Promise<TokenExchange>;
|
|
26
|
+
revoke: (refreshToken: string) => Promise<void>;
|
|
27
|
+
};
|
package/dist/sessions.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
2
|
+
/** Calls the SegmentStream app-session endpoints from the app's server. */
|
|
3
|
+
export function createSessionClient(config, fetchImplementation) {
|
|
4
|
+
async function post(path, body) {
|
|
5
|
+
return fetchImplementation(`${config.baseUrl}${path}`, {
|
|
6
|
+
method: "POST",
|
|
7
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
8
|
+
body: JSON.stringify(body),
|
|
9
|
+
cache: "no-store",
|
|
10
|
+
redirect: "error",
|
|
11
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
async function requestToken(grant) {
|
|
15
|
+
let response;
|
|
16
|
+
try {
|
|
17
|
+
response = await post("/api/v1/app-sessions/token", grant);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return { status: "unavailable" };
|
|
21
|
+
}
|
|
22
|
+
const body = await readJson(response);
|
|
23
|
+
if (response.ok) {
|
|
24
|
+
const tokens = sessionTokensFrom(body);
|
|
25
|
+
return tokens === null ? { status: "unavailable" } : { status: "issued", tokens };
|
|
26
|
+
}
|
|
27
|
+
if (response.status >= 500 || response.status === 429) {
|
|
28
|
+
return { status: "unavailable" };
|
|
29
|
+
}
|
|
30
|
+
return { status: "rejected", httpStatus: response.status, code: errorCodeFrom(body) };
|
|
31
|
+
}
|
|
32
|
+
async function revoke(refreshToken) {
|
|
33
|
+
try {
|
|
34
|
+
const response = await post("/api/v1/app-sessions/revoke", { refreshToken });
|
|
35
|
+
await response.body?.cancel();
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// Sign-out is best effort: the local cookies are cleared either way.
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { requestToken, revoke };
|
|
42
|
+
}
|
|
43
|
+
async function readJson(response) {
|
|
44
|
+
try {
|
|
45
|
+
return await response.json();
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function sessionTokensFrom(body) {
|
|
52
|
+
if (typeof body !== "object" || body === null) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
const { accessToken, expiresIn, refreshToken } = body;
|
|
56
|
+
if (typeof accessToken !== "string" ||
|
|
57
|
+
typeof refreshToken !== "string" ||
|
|
58
|
+
typeof expiresIn !== "number") {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
if (accessToken === "" || refreshToken === "" || !Number.isFinite(expiresIn) || expiresIn <= 0) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
return { accessToken, expiresIn, refreshToken };
|
|
65
|
+
}
|
|
66
|
+
function errorCodeFrom(body) {
|
|
67
|
+
if (typeof body !== "object" || body === null) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const { error } = body;
|
|
71
|
+
return typeof error === "string" ? error : null;
|
|
72
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import type { NextRequest } from "next/server";
|
|
3
|
+
import type { ResolvedConfig } from "./config.js";
|
|
4
|
+
export declare const SDK_ROUTE_PREFIX = "/api/segmentstream";
|
|
5
|
+
export declare const CALLBACK_PATH = "/api/segmentstream/callback";
|
|
6
|
+
export declare const SIGN_IN_PATH = "/api/segmentstream/sign-in";
|
|
7
|
+
export declare const SIGNED_OUT_PATH = "/api/segmentstream/signed-out";
|
|
8
|
+
/**
|
|
9
|
+
* Requests that cannot follow a cross-origin sign-in redirect: anything other than a
|
|
10
|
+
* top-level page navigation. They receive a 401 instead of a redirect.
|
|
11
|
+
*/
|
|
12
|
+
export declare function isApiRequest(request: NextRequest): boolean;
|
|
13
|
+
export declare function unauthenticatedResponse(): NextResponse;
|
|
14
|
+
export interface SignInRedirectInput {
|
|
15
|
+
readonly config: ResolvedConfig;
|
|
16
|
+
readonly request: NextRequest;
|
|
17
|
+
readonly returnTo: string;
|
|
18
|
+
readonly clearSession: boolean;
|
|
19
|
+
}
|
|
20
|
+
/** Starts the authorization-code flow with PKCE by redirecting to SegmentStream. */
|
|
21
|
+
export declare function signInRedirect(input: SignInRedirectInput): Promise<NextResponse>;
|
|
22
|
+
export interface AuthorizationUrlInput {
|
|
23
|
+
readonly config: ResolvedConfig;
|
|
24
|
+
readonly redirectUri: string;
|
|
25
|
+
readonly state: string;
|
|
26
|
+
readonly codeChallenge: string;
|
|
27
|
+
}
|
|
28
|
+
export declare function authorizationUrl(input: AuthorizationUrlInput): URL;
|
package/dist/sign-in.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { clearSessionCookies, cookiePolicyFor, setAuthFlowCookie } from "./cookies.js";
|
|
3
|
+
import { createCodeChallenge, randomToken } from "./pkce.js";
|
|
4
|
+
export const SDK_ROUTE_PREFIX = "/api/segmentstream";
|
|
5
|
+
export const CALLBACK_PATH = `${SDK_ROUTE_PREFIX}/callback`;
|
|
6
|
+
export const SIGN_IN_PATH = `${SDK_ROUTE_PREFIX}/sign-in`;
|
|
7
|
+
export const SIGNED_OUT_PATH = `${SDK_ROUTE_PREFIX}/signed-out`;
|
|
8
|
+
/**
|
|
9
|
+
* Requests that cannot follow a cross-origin sign-in redirect: anything other than a
|
|
10
|
+
* top-level page navigation. They receive a 401 instead of a redirect.
|
|
11
|
+
*/
|
|
12
|
+
export function isApiRequest(request) {
|
|
13
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
if (request.nextUrl.pathname === "/api" || request.nextUrl.pathname.startsWith("/api/")) {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
// Next.js strips some Flight headers before Proxy runs; `_rsc` and Accept still identify those requests.
|
|
20
|
+
const flightHeaders = ["rsc", "next-router-prefetch", "next-router-state-tree", "next-action"];
|
|
21
|
+
if (flightHeaders.some((header) => request.headers.has(header)) ||
|
|
22
|
+
request.nextUrl.searchParams.has("_rsc")) {
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
return !(request.headers.get("accept") ?? "").includes("text/html");
|
|
26
|
+
}
|
|
27
|
+
export function unauthenticatedResponse() {
|
|
28
|
+
return NextResponse.json({ error: "unauthenticated" }, { status: 401, headers: { "cache-control": "no-store" } });
|
|
29
|
+
}
|
|
30
|
+
/** Starts the authorization-code flow with PKCE by redirecting to SegmentStream. */
|
|
31
|
+
export async function signInRedirect(input) {
|
|
32
|
+
const { config, request, returnTo, clearSession } = input;
|
|
33
|
+
const policy = cookiePolicyFor(request.nextUrl);
|
|
34
|
+
const state = randomToken();
|
|
35
|
+
const codeVerifier = randomToken();
|
|
36
|
+
const codeChallenge = await createCodeChallenge(codeVerifier);
|
|
37
|
+
const redirectUri = `${request.nextUrl.origin}${CALLBACK_PATH}`;
|
|
38
|
+
const authorizeUrl = authorizationUrl({ config, redirectUri, state, codeChallenge });
|
|
39
|
+
const response = NextResponse.redirect(authorizeUrl, 307);
|
|
40
|
+
response.headers.set("cache-control", "no-store");
|
|
41
|
+
setAuthFlowCookie(response, policy, { state, codeVerifier, redirectUri, returnTo });
|
|
42
|
+
if (clearSession) {
|
|
43
|
+
clearSessionCookies(response, policy);
|
|
44
|
+
}
|
|
45
|
+
return response;
|
|
46
|
+
}
|
|
47
|
+
export function authorizationUrl(input) {
|
|
48
|
+
const url = new URL(`${input.config.baseUrl}/auth/apps/authorize`);
|
|
49
|
+
url.searchParams.set("workspace_id", input.config.workspaceId);
|
|
50
|
+
url.searchParams.set("organization_id", input.config.organizationId);
|
|
51
|
+
url.searchParams.set("redirect_uri", input.redirectUri);
|
|
52
|
+
url.searchParams.set("state", input.state);
|
|
53
|
+
url.searchParams.set("code_challenge", input.codeChallenge);
|
|
54
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
55
|
+
return url;
|
|
56
|
+
}
|
package/dist/tokens.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { JWTVerifyGetKey } from "jose";
|
|
2
|
+
import type { ResolvedConfig } from "./config.js";
|
|
3
|
+
/** The signed-in SegmentStream user, as asserted by the app session token. */
|
|
4
|
+
export interface AppUser {
|
|
5
|
+
readonly id: string;
|
|
6
|
+
readonly email?: string;
|
|
7
|
+
readonly name?: string;
|
|
8
|
+
readonly organizationId: string;
|
|
9
|
+
readonly workspaceId: string;
|
|
10
|
+
}
|
|
11
|
+
export type KeySet = JWTVerifyGetKey;
|
|
12
|
+
export interface VerifiedToken {
|
|
13
|
+
readonly status: "valid";
|
|
14
|
+
readonly user: AppUser;
|
|
15
|
+
readonly token: string;
|
|
16
|
+
}
|
|
17
|
+
export type TokenVerification = VerifiedToken | {
|
|
18
|
+
readonly status: "expired";
|
|
19
|
+
} | {
|
|
20
|
+
readonly status: "invalid";
|
|
21
|
+
} | {
|
|
22
|
+
readonly status: "unavailable";
|
|
23
|
+
};
|
|
24
|
+
/** One remote JWKS per SegmentStream origin, shared by every app instance in the process. */
|
|
25
|
+
export declare function remoteKeySet(baseUrl: string): KeySet;
|
|
26
|
+
export declare function createTokenVerifier(config: ResolvedConfig, keySet: KeySet): (token: string) => Promise<TokenVerification>;
|