@segmentstream-cloud/app 0.0.0-stage → 0.1.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 +112 -2
- package/dist/app.d.ts +23 -0
- package/dist/app.js +26 -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/handlers.d.ts +8 -0
- package/dist/handlers.js +133 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +14 -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
package/dist/handlers.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { clearAuthFlowCookie, clearSessionCookies, cookiePolicyFor, decodeAuthFlowState, safeReturnTo, setSessionCookies, } from "./cookies.js";
|
|
3
|
+
import { constantTimeEqual } from "./pkce.js";
|
|
4
|
+
import { CALLBACK_PATH, SIGNED_OUT_PATH, SIGN_IN_PATH, SDK_ROUTE_PREFIX, signInRedirect, } from "./sign-in.js";
|
|
5
|
+
export function createHandlers(context) {
|
|
6
|
+
async function GET(request) {
|
|
7
|
+
switch (request.nextUrl.pathname) {
|
|
8
|
+
case CALLBACK_PATH:
|
|
9
|
+
return handleCallback(context, request);
|
|
10
|
+
case SIGN_IN_PATH:
|
|
11
|
+
return signInRedirect({
|
|
12
|
+
config: context.config,
|
|
13
|
+
request,
|
|
14
|
+
returnTo: safeReturnTo(request.nextUrl.searchParams.get("returnTo")),
|
|
15
|
+
clearSession: false,
|
|
16
|
+
});
|
|
17
|
+
case SIGNED_OUT_PATH:
|
|
18
|
+
return htmlResponse(200, "You're signed out", "You have signed out of this app.", "Sign in again");
|
|
19
|
+
default:
|
|
20
|
+
return notFound();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
async function POST(request) {
|
|
24
|
+
if (request.nextUrl.pathname === `${SDK_ROUTE_PREFIX}/sign-out`) {
|
|
25
|
+
return handleSignOut(context, request);
|
|
26
|
+
}
|
|
27
|
+
return notFound();
|
|
28
|
+
}
|
|
29
|
+
return { GET, POST };
|
|
30
|
+
}
|
|
31
|
+
async function handleCallback(context, request) {
|
|
32
|
+
const policy = cookiePolicyFor(request.nextUrl);
|
|
33
|
+
const flow = decodeAuthFlowState(request.cookies.get(policy.names.authFlow)?.value);
|
|
34
|
+
const code = request.nextUrl.searchParams.get("code");
|
|
35
|
+
const state = request.nextUrl.searchParams.get("state");
|
|
36
|
+
if (request.nextUrl.searchParams.get("error") === "access_denied") {
|
|
37
|
+
return callbackFailure(policy, 403, "You don't have access to this app.");
|
|
38
|
+
}
|
|
39
|
+
if (flow === null ||
|
|
40
|
+
code === null ||
|
|
41
|
+
code === "" ||
|
|
42
|
+
state === null ||
|
|
43
|
+
!constantTimeEqual(state, flow.state)) {
|
|
44
|
+
return callbackFailure(policy, 400, "This sign-in link is invalid or has expired.");
|
|
45
|
+
}
|
|
46
|
+
const exchange = await context.requestToken({
|
|
47
|
+
grantType: "authorization_code",
|
|
48
|
+
code,
|
|
49
|
+
codeVerifier: flow.codeVerifier,
|
|
50
|
+
redirectUri: flow.redirectUri,
|
|
51
|
+
});
|
|
52
|
+
if (exchange.status === "unavailable") {
|
|
53
|
+
return callbackFailure(policy, 503, "Sign-in is temporarily unavailable. Try again in a moment.");
|
|
54
|
+
}
|
|
55
|
+
if (exchange.status === "rejected") {
|
|
56
|
+
const accessRevoked = exchange.httpStatus === 403 || exchange.code === "access_revoked";
|
|
57
|
+
return accessRevoked
|
|
58
|
+
? callbackFailure(policy, 403, "You don't have access to this app.")
|
|
59
|
+
: callbackFailure(policy, 400, "Sign-in could not be completed. Try again.");
|
|
60
|
+
}
|
|
61
|
+
// A token this app would reject must not become a session, or sign-in would loop.
|
|
62
|
+
const verification = await context.verifyToken(exchange.tokens.accessToken);
|
|
63
|
+
if (verification.status === "unavailable") {
|
|
64
|
+
return callbackFailure(policy, 503, "Sign-in is temporarily unavailable. Try again in a moment.");
|
|
65
|
+
}
|
|
66
|
+
if (verification.status !== "valid") {
|
|
67
|
+
return callbackFailure(policy, 400, "Sign-in returned a session for a different app.");
|
|
68
|
+
}
|
|
69
|
+
const response = NextResponse.redirect(new URL(flow.returnTo, request.nextUrl.origin), 303);
|
|
70
|
+
response.headers.set("cache-control", "no-store");
|
|
71
|
+
clearAuthFlowCookie(response, policy);
|
|
72
|
+
setSessionCookies(response, policy, exchange.tokens);
|
|
73
|
+
return response;
|
|
74
|
+
}
|
|
75
|
+
async function handleSignOut(context, request) {
|
|
76
|
+
const origin = request.headers.get("origin");
|
|
77
|
+
if (origin !== null && origin !== request.nextUrl.origin) {
|
|
78
|
+
return htmlResponse(403, "Sign-out refused", "Sign out from this app's own pages.", "Return to the app");
|
|
79
|
+
}
|
|
80
|
+
const policy = cookiePolicyFor(request.nextUrl);
|
|
81
|
+
const refreshToken = request.cookies.get(policy.names.refresh)?.value;
|
|
82
|
+
if (refreshToken !== undefined && refreshToken !== "") {
|
|
83
|
+
await context.revokeSession(refreshToken);
|
|
84
|
+
}
|
|
85
|
+
const response = NextResponse.redirect(new URL(SIGNED_OUT_PATH, request.nextUrl.origin), 303);
|
|
86
|
+
response.headers.set("cache-control", "no-store");
|
|
87
|
+
clearSessionCookies(response, policy);
|
|
88
|
+
clearAuthFlowCookie(response, policy);
|
|
89
|
+
return response;
|
|
90
|
+
}
|
|
91
|
+
function callbackFailure(policy, status, message) {
|
|
92
|
+
const response = htmlResponse(status, "Sign-in failed", message, "Try again");
|
|
93
|
+
clearAuthFlowCookie(response, policy);
|
|
94
|
+
return response;
|
|
95
|
+
}
|
|
96
|
+
function notFound() {
|
|
97
|
+
return NextResponse.json({ error: "not_found" }, { status: 404, headers: { "cache-control": "no-store" } });
|
|
98
|
+
}
|
|
99
|
+
function htmlResponse(status, title, message, linkLabel) {
|
|
100
|
+
const body = [
|
|
101
|
+
"<!doctype html>",
|
|
102
|
+
'<html lang="en">',
|
|
103
|
+
"<head>",
|
|
104
|
+
'<meta charset="utf-8">',
|
|
105
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
106
|
+
'<meta name="robots" content="noindex">',
|
|
107
|
+
`<title>${escapeHtml(title)}</title>`,
|
|
108
|
+
"<style>body{font-family:system-ui,sans-serif;max-width:32rem;margin:15vh auto;padding:0 1rem;}</style>",
|
|
109
|
+
"</head>",
|
|
110
|
+
"<body>",
|
|
111
|
+
`<h1>${escapeHtml(title)}</h1>`,
|
|
112
|
+
`<p>${escapeHtml(message)}</p>`,
|
|
113
|
+
`<p><a href="/">${escapeHtml(linkLabel)}</a></p>`,
|
|
114
|
+
"</body>",
|
|
115
|
+
"</html>",
|
|
116
|
+
].join("\n");
|
|
117
|
+
return new NextResponse(body, {
|
|
118
|
+
status,
|
|
119
|
+
headers: {
|
|
120
|
+
"content-type": "text/html; charset=utf-8",
|
|
121
|
+
"cache-control": "no-store",
|
|
122
|
+
"x-content-type-options": "nosniff",
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
function escapeHtml(value) {
|
|
127
|
+
return value
|
|
128
|
+
.replace(/&/g, "&")
|
|
129
|
+
.replace(/</g, "<")
|
|
130
|
+
.replace(/>/g, ">")
|
|
131
|
+
.replace(/"/g, """)
|
|
132
|
+
.replace(/'/g, "'");
|
|
133
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { SegmentStreamApp } from "./app.js";
|
|
2
|
+
import type { SegmentStreamAppConfig } from "./config.js";
|
|
3
|
+
/**
|
|
4
|
+
* Creates the SegmentStream sign-in integration for a Next.js workspace app.
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* export const segmentstream = createSegmentStreamApp({
|
|
8
|
+
* workspaceId: "wsp_...",
|
|
9
|
+
* organizationId: "org_...",
|
|
10
|
+
* });
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
export declare function createSegmentStreamApp(config: SegmentStreamAppConfig): SegmentStreamApp;
|
|
14
|
+
export type { SegmentStreamApp } from "./app.js";
|
|
15
|
+
export type { SegmentStreamAppConfig } from "./config.js";
|
|
16
|
+
export type { SegmentStreamRouteHandlers, RouteHandler } from "./handlers.js";
|
|
17
|
+
export type { ProxyOptions, SegmentStreamProxy } from "./proxy.js";
|
|
18
|
+
export type { AppUser } from "./tokens.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createApp } from "./app.js";
|
|
2
|
+
/**
|
|
3
|
+
* Creates the SegmentStream sign-in integration for a Next.js workspace app.
|
|
4
|
+
*
|
|
5
|
+
* ```ts
|
|
6
|
+
* export const segmentstream = createSegmentStreamApp({
|
|
7
|
+
* workspaceId: "wsp_...",
|
|
8
|
+
* organizationId: "org_...",
|
|
9
|
+
* });
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
export function createSegmentStreamApp(config) {
|
|
13
|
+
return createApp(config);
|
|
14
|
+
}
|
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>;
|
package/dist/tokens.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { createRemoteJWKSet, errors, jwtVerify } from "jose";
|
|
2
|
+
const CLOCK_TOLERANCE_SECONDS = 5;
|
|
3
|
+
const remoteKeySets = new Map();
|
|
4
|
+
/** One remote JWKS per SegmentStream origin, shared by every app instance in the process. */
|
|
5
|
+
export function remoteKeySet(baseUrl) {
|
|
6
|
+
const existing = remoteKeySets.get(baseUrl);
|
|
7
|
+
if (existing !== undefined) {
|
|
8
|
+
return existing;
|
|
9
|
+
}
|
|
10
|
+
const keySet = createRemoteJWKSet(new URL(`${baseUrl}/api/v1/app-sessions/jwks`), {
|
|
11
|
+
timeoutDuration: 5_000,
|
|
12
|
+
});
|
|
13
|
+
remoteKeySets.set(baseUrl, keySet);
|
|
14
|
+
return keySet;
|
|
15
|
+
}
|
|
16
|
+
export function createTokenVerifier(config, keySet) {
|
|
17
|
+
return async function verifyToken(token) {
|
|
18
|
+
let payload;
|
|
19
|
+
try {
|
|
20
|
+
const verified = await jwtVerify(token, keySet, {
|
|
21
|
+
algorithms: ["ES256"],
|
|
22
|
+
issuer: config.issuer,
|
|
23
|
+
clockTolerance: CLOCK_TOLERANCE_SECONDS,
|
|
24
|
+
requiredClaims: ["sub", "exp", "iat", "org", "wsp"],
|
|
25
|
+
});
|
|
26
|
+
payload = verified.payload;
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
return classifyVerificationFailure(error);
|
|
30
|
+
}
|
|
31
|
+
const user = appUserFromClaims(config, payload);
|
|
32
|
+
return user === null ? { status: "invalid" } : { status: "valid", user, token };
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function classifyVerificationFailure(error) {
|
|
36
|
+
if (error instanceof errors.JWTExpired) {
|
|
37
|
+
return { status: "expired" };
|
|
38
|
+
}
|
|
39
|
+
if (error instanceof errors.JWKSTimeout) {
|
|
40
|
+
return { status: "unavailable" };
|
|
41
|
+
}
|
|
42
|
+
if (error instanceof errors.JOSEError) {
|
|
43
|
+
return { status: "invalid" };
|
|
44
|
+
}
|
|
45
|
+
// Network failures while fetching the key set are not token defects.
|
|
46
|
+
return { status: "unavailable" };
|
|
47
|
+
}
|
|
48
|
+
function appUserFromClaims(config, payload) {
|
|
49
|
+
const { sub, org, wsp, email, name } = payload;
|
|
50
|
+
if (typeof sub !== "string" ||
|
|
51
|
+
sub === "" ||
|
|
52
|
+
org !== config.organizationId ||
|
|
53
|
+
wsp !== config.workspaceId) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
if (!hasAppAudience(payload.aud)) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
id: sub,
|
|
61
|
+
...(typeof email === "string" ? { email } : {}),
|
|
62
|
+
...(typeof name === "string" ? { name } : {}),
|
|
63
|
+
organizationId: org,
|
|
64
|
+
workspaceId: wsp,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function hasAppAudience(audience) {
|
|
68
|
+
const audiences = typeof audience === "string" ? [audience] : (audience ?? []);
|
|
69
|
+
return audiences.some((candidate) => candidate.startsWith("app:"));
|
|
70
|
+
}
|