@flow-industries/id 0.23.0 → 0.23.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 CHANGED
@@ -143,3 +143,5 @@ The current OpenAPI artifact describes the service report boundary. The remainin
143
143
  account, room management, study, settings, session and passkey HTTP operations
144
144
  still need complete request/response descriptions and generated client coverage
145
145
  before the full HTTP contract work is complete.
146
+
147
+ Server deployments may set `FLOW_ID_API_URL` to a trusted internal HTTP(S) origin for API and JWKS requests, or pass `apiUrl` to server session/verification helpers. `issuerUrl` remains the public JWT issuer and browser navigation origin. The transport URL must contain no credentials, path, query, or fragment.
@@ -4,5 +4,5 @@ export declare function startBrowserSession(request: Request, audience: string,
4
4
  returnTo?: string;
5
5
  signUp?: boolean;
6
6
  accessKeyHash?: string;
7
- }): Promise<Response>;
8
- export declare function finishBrowserSession(request: Request, audience: string, issuerUrl: string): Promise<Response>;
7
+ }, apiUrl?: string): Promise<Response>;
8
+ export declare function finishBrowserSession(request: Request, audience: string, issuerUrl: string, apiUrl?: string): Promise<Response>;
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { browserSigningSchema } from "./browser-contract";
3
3
  import { clearCookieString, cookieNamesFor, parseCookieHeader, serializeCookie, } from "./cookies";
4
+ import { fetchIssuerApi, resolveIssuerApiUrl } from "./issuer-api";
4
5
  import { claimsToUser, sessionCookies } from "./session-core";
5
6
  import { verifyFlowJWT } from "./verify";
6
7
  const opaquePattern = /^[A-Za-z0-9_-]{43}$/;
@@ -60,7 +61,7 @@ export function safeReturnTo(value, audience, sessionPath = "/flow/session") {
60
61
  return null;
61
62
  }
62
63
  }
63
- export async function startBrowserSession(request, audience, issuerUrl, body) {
64
+ export async function startBrowserSession(request, audience, issuerUrl, body, apiUrl = resolveIssuerApiUrl(issuerUrl)) {
64
65
  const cookies = parseCookieHeader(request.headers.get("Cookie"));
65
66
  const refresh = cookies[cookieNamesFor(audience).refresh];
66
67
  const headers = new Headers({ "Content-Type": "application/json" });
@@ -74,7 +75,7 @@ export async function startBrowserSession(request, audience, issuerUrl, body) {
74
75
  if (body.action === "account") {
75
76
  if (!refresh)
76
77
  return reply({ error: "signed_out" }, 401);
77
- const response = await fetch(`${issuerUrl}/api/auth/browser/account-handoff`, { method: "POST", headers, body: JSON.stringify({ audience }) });
78
+ const response = await fetchIssuerApi(`${apiUrl}/api/auth/browser/account-handoff`, { method: "POST", headers, body: JSON.stringify({ audience }) });
78
79
  if (!response.ok)
79
80
  return reply({ error: "account_unavailable" }, response.status);
80
81
  const { handoff } = z
@@ -88,7 +89,7 @@ export async function startBrowserSession(request, audience, issuerUrl, body) {
88
89
  const state = secret();
89
90
  const verifier = secret();
90
91
  const callback = `${audience}${new URL(request.url).pathname}`;
91
- const response = await fetch(`${issuerUrl}/api/auth/browser/transaction`, {
92
+ const response = await fetchIssuerApi(`${apiUrl}/api/auth/browser/transaction`, {
92
93
  method: "POST",
93
94
  headers,
94
95
  body: JSON.stringify({
@@ -121,7 +122,7 @@ export async function startBrowserSession(request, audience, issuerUrl, body) {
121
122
  }),
122
123
  ]);
123
124
  }
124
- export async function finishBrowserSession(request, audience, issuerUrl) {
125
+ export async function finishBrowserSession(request, audience, issuerUrl, apiUrl = resolveIssuerApiUrl(issuerUrl)) {
125
126
  const url = new URL(request.url);
126
127
  const state = url.searchParams.get("flow_state") ?? "";
127
128
  if (!opaquePattern.test(state))
@@ -154,7 +155,7 @@ export async function finishBrowserSession(request, audience, issuerUrl) {
154
155
  if (!opaquePattern.test(code))
155
156
  return reply({ error: "invalid_login_code" }, 400);
156
157
  try {
157
- const result = await fetch(`${issuerUrl}/api/auth/browser/token`, {
158
+ const result = await fetchIssuerApi(`${apiUrl}/api/auth/browser/token`, {
158
159
  method: "POST",
159
160
  headers: { "Content-Type": "application/json" },
160
161
  body: JSON.stringify({
@@ -170,7 +171,11 @@ export async function finishBrowserSession(request, audience, issuerUrl) {
170
171
  message: "Return to your app and start sign-in again.",
171
172
  }, 400, [clear]);
172
173
  const session = exchangeSchema.parse(await result.json());
173
- const claims = await verifyFlowJWT(session.jwt, { audience, issuerUrl });
174
+ const claims = await verifyFlowJWT(session.jwt, {
175
+ audience,
176
+ issuerUrl,
177
+ apiUrl,
178
+ });
174
179
  if (!claimsToUser(claims).id)
175
180
  throw new Error("Invalid session");
176
181
  for (const value of sessionCookies(audience, session.jwt, session.refreshToken))
@@ -0,0 +1,3 @@
1
+ /** Resolve a trusted server transport without changing the public JWT issuer. */
2
+ export declare function resolveIssuerApiUrl(issuerUrl: string, override?: string): string;
3
+ export declare function fetchIssuerApi(url: string, init?: RequestInit): Promise<Response>;
@@ -0,0 +1,28 @@
1
+ /** Resolve a trusted server transport without changing the public JWT issuer. */
2
+ export function resolveIssuerApiUrl(issuerUrl, override) {
3
+ const configured = override ??
4
+ (globalThis.process !== undefined
5
+ ? process.env.FLOW_ID_API_URL
6
+ : undefined);
7
+ const url = new URL(configured ?? issuerUrl);
8
+ if (!["http:", "https:"].includes(url.protocol) ||
9
+ url.username ||
10
+ url.password ||
11
+ url.search ||
12
+ url.hash ||
13
+ url.pathname !== "/") {
14
+ throw new Error("Flow ID API URL must be an HTTP(S) origin without credentials, path, query, or fragment");
15
+ }
16
+ return url.origin;
17
+ }
18
+ export async function fetchIssuerApi(url, init) {
19
+ const response = await fetch(url, { ...init, redirect: "error" });
20
+ if (!response.ok) {
21
+ console.warn("Flow ID API request failed", {
22
+ path: new URL(url).pathname,
23
+ status: response.status,
24
+ contentType: response.headers.get("content-type")?.split(";")[0] ?? null,
25
+ });
26
+ }
27
+ return response;
28
+ }
@@ -1,10 +1,11 @@
1
1
  import { resolveIdHost } from "./id-host";
2
+ import { fetchIssuerApi, resolveIssuerApiUrl } from "./issuer-api";
2
3
  import { resolveSessionCore } from "./session-core";
3
4
  export { createSessionHandler, handleSessionRequest } from "./session-route";
4
5
  export { verifyFlowJWT } from "./verify";
5
- async function fetchProfile(issuerUrl, jwt, audience) {
6
+ async function fetchProfile(apiUrl, jwt, audience) {
6
7
  try {
7
- const res = await fetch(`${issuerUrl}/api/session/verify`, {
8
+ const res = await fetchIssuerApi(`${apiUrl}/api/session/verify`, {
8
9
  method: "POST",
9
10
  headers: { "Content-Type": "application/json" },
10
11
  body: JSON.stringify({ token: jwt, audience }),
@@ -29,12 +30,13 @@ async function fetchProfile(issuerUrl, jwt, audience) {
29
30
  */
30
31
  export async function resolveSession(cookieHeader, opts) {
31
32
  const issuerUrl = resolveIdHost(opts.issuerUrl, opts.audience);
32
- const { session, claims, setCookies } = await resolveSessionCore(cookieHeader, opts.audience, issuerUrl);
33
+ const apiUrl = resolveIssuerApiUrl(issuerUrl, opts.apiUrl);
34
+ const { session, claims, setCookies } = await resolveSessionCore(cookieHeader, opts.audience, issuerUrl, apiUrl);
33
35
  const state = session
34
36
  ? { user: session.user, jwt: session.jwt, address: session.address ?? null }
35
37
  : null;
36
38
  const profile = state && opts.profile
37
- ? await fetchProfile(issuerUrl, state.jwt, opts.audience)
39
+ ? await fetchProfile(apiUrl, state.jwt, opts.audience)
38
40
  : undefined;
39
41
  return {
40
42
  state,
@@ -27,7 +27,7 @@ export type ResolvedSessionCore = {
27
27
  * happened) but resolves signed-out rather than trusting it. Returns "dead"
28
28
  * for a definitive rejection and null for a transient network failure.
29
29
  */
30
- export declare function refreshSessionCore(issuerUrl: string, audience: string, refreshToken: string): Promise<ResolvedSessionCore | "dead" | null>;
30
+ export declare function refreshSessionCore(issuerUrl: string, audience: string, refreshToken: string, apiUrl?: string): Promise<ResolvedSessionCore | "dead" | null>;
31
31
  /**
32
32
  * Resolves a session from a request's Cookie header.
33
33
  *
@@ -41,4 +41,4 @@ export declare function refreshSessionCore(issuerUrl: string, audience: string,
41
41
  * network failure leaves the cookies alone so a later request can retry.
42
42
  * 3. Neither cookie → signed-out `session: null`.
43
43
  */
44
- export declare function resolveSessionCore(cookieHeader: string | null | undefined, audience: string, issuerUrl: string): Promise<ResolvedSessionCore>;
44
+ export declare function resolveSessionCore(cookieHeader: string | null | undefined, audience: string, issuerUrl: string, apiUrl?: string): Promise<ResolvedSessionCore>;
@@ -5,6 +5,7 @@ import { errField } from "./driver-error";
5
5
  * `session-route.ts` compose it into the public entry points.
6
6
  */
7
7
  import { clearCookieString, cookieNamesFor, JWT_COOKIE_MAX_AGE_S, parseCookieHeader, REFRESH_COOKIE_MAX_AGE_S, serializeCookie, } from "./cookies";
8
+ import { fetchIssuerApi, resolveIssuerApiUrl } from "./issuer-api";
8
9
  import { isExpiring } from "./token-expiry";
9
10
  import { verifyFlowJWT } from "./verify";
10
11
  export function claimsToUser(claims) {
@@ -16,9 +17,9 @@ export function claimsToUser(claims) {
16
17
  isGuest: claims.guest !== false,
17
18
  };
18
19
  }
19
- async function refreshSession(issuerUrl, token) {
20
+ async function refreshSession(token, apiUrl) {
20
21
  try {
21
- const res = await fetch(`${issuerUrl}/api/session/refresh`, {
22
+ const res = await fetchIssuerApi(`${apiUrl}/api/session/refresh`, {
22
23
  method: "POST",
23
24
  headers: { Authorization: `Bearer ${token}` },
24
25
  });
@@ -42,11 +43,11 @@ async function refreshSession(issuerUrl, token) {
42
43
  // other machines).
43
44
  const REFRESH_RESULT_TTL_MS = 30_000;
44
45
  const refreshInFlight = new Map();
45
- function refreshOnce(issuerUrl, token) {
46
- const key = `${issuerUrl}\n${token}`;
46
+ function refreshOnce(issuerUrl, token, apiUrl) {
47
+ const key = `${issuerUrl}\n${apiUrl}\n${token}`;
47
48
  let flight = refreshInFlight.get(key);
48
49
  if (!flight) {
49
- flight = refreshSession(issuerUrl, token);
50
+ flight = refreshSession(token, apiUrl);
50
51
  refreshInFlight.set(key, flight);
51
52
  void flight.finally(() => {
52
53
  const timer = setTimeout(() => refreshInFlight.delete(key), REFRESH_RESULT_TTL_MS);
@@ -100,14 +101,14 @@ export function clearingCookies(audience) {
100
101
  * happened) but resolves signed-out rather than trusting it. Returns "dead"
101
102
  * for a definitive rejection and null for a transient network failure.
102
103
  */
103
- export async function refreshSessionCore(issuerUrl, audience, refreshToken) {
104
- const result = await refreshOnce(issuerUrl, refreshToken);
104
+ export async function refreshSessionCore(issuerUrl, audience, refreshToken, apiUrl = resolveIssuerApiUrl(issuerUrl)) {
105
+ const result = await refreshOnce(issuerUrl, refreshToken, apiUrl);
105
106
  if (result === "dead" || !result)
106
107
  return result;
107
108
  const successorCookies = sessionCookies(audience, result.jwt, result.refreshToken);
108
109
  let claims;
109
110
  try {
110
- claims = await verifyFlowJWT(result.jwt, { audience, issuerUrl });
111
+ claims = await verifyFlowJWT(result.jwt, { audience, issuerUrl, apiUrl });
111
112
  }
112
113
  catch (err) {
113
114
  if (isJwtVerificationFailure(err)) {
@@ -143,13 +144,13 @@ export async function refreshSessionCore(issuerUrl, audience, refreshToken) {
143
144
  * network failure leaves the cookies alone so a later request can retry.
144
145
  * 3. Neither cookie → signed-out `session: null`.
145
146
  */
146
- export async function resolveSessionCore(cookieHeader, audience, issuerUrl) {
147
+ export async function resolveSessionCore(cookieHeader, audience, issuerUrl, apiUrl = resolveIssuerApiUrl(issuerUrl)) {
147
148
  const names = cookieNamesFor(audience);
148
149
  const cookies = parseCookieHeader(cookieHeader);
149
150
  const jwt = cookies[names.jwt];
150
151
  if (jwt && !isExpiring(jwt)) {
151
152
  try {
152
- const claims = await verifyFlowJWT(jwt, { audience, issuerUrl });
153
+ const claims = await verifyFlowJWT(jwt, { audience, issuerUrl, apiUrl });
153
154
  return {
154
155
  session: { user: claimsToUser(claims), jwt },
155
156
  claims,
@@ -165,7 +166,7 @@ export async function resolveSessionCore(cookieHeader, audience, issuerUrl) {
165
166
  const refreshToken = cookies[names.refresh];
166
167
  if (!refreshToken)
167
168
  return { session: null, claims: null, setCookies: [] };
168
- const refreshed = await refreshSessionCore(issuerUrl, audience, refreshToken);
169
+ const refreshed = await refreshSessionCore(issuerUrl, audience, refreshToken, apiUrl);
169
170
  if (refreshed === "dead") {
170
171
  return {
171
172
  session: null,
@@ -2,6 +2,7 @@ import { z } from "zod";
2
2
  import { browserCompletionSchema } from "./browser-contract";
3
3
  import { finishBrowserSession, startBrowserSession, } from "./browser-session-route";
4
4
  import { clearCookieString } from "./cookies";
5
+ import { fetchIssuerApi, resolveIssuerApiUrl } from "./issuer-api";
5
6
  /**
6
7
  * The first-party session route every consumer app serves from its own
7
8
  * origin — the only path between a browser and the refresh token. The
@@ -82,9 +83,10 @@ function isCrossSite(request, audience) {
82
83
  export async function handleSessionRequest(request, opts = {}) {
83
84
  const audience = opts.audience ?? defaultAudience(new URL(request.url).origin);
84
85
  const issuerUrl = resolveIdHost(opts.issuerUrl, audience);
86
+ const apiUrl = resolveIssuerApiUrl(issuerUrl, opts.apiUrl);
85
87
  const callback = new URL(request.url);
86
88
  if (request.method === "GET" && callback.searchParams.has("flow_state")) {
87
- return finishBrowserSession(request, audience, issuerUrl);
89
+ return finishBrowserSession(request, audience, issuerUrl, apiUrl);
88
90
  }
89
91
  if (isCrossSite(request, audience)) {
90
92
  return json({ state: null }, 403);
@@ -95,14 +97,14 @@ export async function handleSessionRequest(request, opts = {}) {
95
97
  const token = parseCookieHeader(request.headers.get("Cookie"))[cookieNamesFor(audience).refresh];
96
98
  if (!token)
97
99
  return json({ state: null }, 200);
98
- const result = await refreshSessionCore(issuerUrl, audience, token);
100
+ const result = await refreshSessionCore(issuerUrl, audience, token, apiUrl);
99
101
  if (result === "dead")
100
102
  return json({ state: null }, 200, clearingCookies(audience));
101
103
  if (!result)
102
104
  return json({ state: null }, 502);
103
105
  return json({ state: result.session }, 200, result.setCookies);
104
106
  }
105
- const { session, setCookies } = await resolveSessionCore(request.headers.get("Cookie"), audience, issuerUrl);
107
+ const { session, setCookies } = await resolveSessionCore(request.headers.get("Cookie"), audience, issuerUrl, apiUrl);
106
108
  const completionName = `${cookieNamesFor(audience).refresh}.completion`;
107
109
  const raw = parseCookieHeader(request.headers.get("Cookie"))[completionName];
108
110
  let loginCompletion;
@@ -129,14 +131,14 @@ export async function handleSessionRequest(request, opts = {}) {
129
131
  return await startBrowserSession(request, audience, issuerUrl, {
130
132
  ...body,
131
133
  action: body.action,
132
- });
134
+ }, apiUrl);
133
135
  }
134
136
  catch {
135
137
  return json({ state: null }, 502);
136
138
  }
137
139
  }
138
140
  if (body?.action === "guest") {
139
- const existing = await resolveSessionCore(request.headers.get("Cookie"), audience, issuerUrl);
141
+ const existing = await resolveSessionCore(request.headers.get("Cookie"), audience, issuerUrl, apiUrl);
140
142
  if (existing.session)
141
143
  return json({ state: existing.session }, 200, existing.setCookies);
142
144
  const previousRefresh = parseCookieHeader(request.headers.get("Cookie"))[cookieNamesFor(audience).refresh];
@@ -153,7 +155,7 @@ export async function handleSessionRequest(request, opts = {}) {
153
155
  if (value)
154
156
  headers.set(name, value);
155
157
  }
156
- const result = await fetch(`${issuerUrl}/api/auth/passkey/guest`, {
158
+ const result = await fetchIssuerApi(`${apiUrl}/api/auth/passkey/guest`, {
157
159
  method: "POST",
158
160
  headers,
159
161
  body: JSON.stringify({ audience }),
@@ -162,7 +164,11 @@ export async function handleSessionRequest(request, opts = {}) {
162
164
  return json({ state: null }, result.status);
163
165
  // SAFETY: the configured issuer returns this session token pair, verified below.
164
166
  const data = (await result.json());
165
- const claims = await verifyFlowJWT(data.jwt, { audience, issuerUrl });
167
+ const claims = await verifyFlowJWT(data.jwt, {
168
+ audience,
169
+ issuerUrl,
170
+ apiUrl,
171
+ });
166
172
  return json({
167
173
  state: {
168
174
  user: claimsToUser(claims),
@@ -188,7 +194,7 @@ export async function handleSessionRequest(request, opts = {}) {
188
194
  let additionalSessions = [];
189
195
  if (token && audiences.length > 0) {
190
196
  try {
191
- const res = await fetch(`${issuerUrl}/api/session/additional`, {
197
+ const res = await fetchIssuerApi(`${apiUrl}/api/session/additional`, {
192
198
  method: "POST",
193
199
  headers: {
194
200
  "Content-Type": "application/json",
@@ -216,7 +222,11 @@ export async function handleSessionRequest(request, opts = {}) {
216
222
  // for another app from being installed here; a failed install must not
217
223
  // clear cookies that may hold a live session.
218
224
  try {
219
- const claims = await verifyFlowJWT(jwt, { audience, issuerUrl });
225
+ const claims = await verifyFlowJWT(jwt, {
226
+ audience,
227
+ issuerUrl,
228
+ apiUrl,
229
+ });
220
230
  return json({ state: { user: claimsToUser(claims), jwt } }, 200, sessionCookies(audience, jwt, refreshToken));
221
231
  }
222
232
  catch (err) {
@@ -230,7 +240,7 @@ export async function handleSessionRequest(request, opts = {}) {
230
240
  // an unreachable issuer just leaves the lineage to expire on its own.
231
241
  if (token) {
232
242
  try {
233
- const response = await fetch(`${issuerUrl}/api/session/revoke`, {
243
+ const response = await fetchIssuerApi(`${apiUrl}/api/session/revoke`, {
234
244
  method: "POST",
235
245
  headers: { Authorization: `Bearer ${token}` },
236
246
  });
@@ -99,6 +99,8 @@ export type PasskeyPluginOptions = {
99
99
  export type VerifyOptions = {
100
100
  audience: string;
101
101
  issuerUrl?: string;
102
+ /** Trusted server API origin; defaults to FLOW_ID_API_URL, then the public issuer. */
103
+ apiUrl?: string;
102
104
  };
103
105
  /**
104
106
  * One entry in the signed-in user's session list (`GET /api/account/sessions`).
@@ -6,6 +6,8 @@ export type ResolveSessionOptions = {
6
6
  audience: string;
7
7
  /** Flow ID origin; defaults to `https://id.flow.industries`. */
8
8
  issuerUrl?: string;
9
+ /** Trusted server API origin; defaults to FLOW_ID_API_URL, then the public issuer. */
10
+ apiUrl?: string;
9
11
  /**
10
12
  * Also fetch the public profile (avatar, tier, level) for a resolved
11
13
  * session via `/api/session/verify` — one extra round-trip; off by default.
@@ -36,6 +38,8 @@ export type SessionRouteOptions = {
36
38
  audience?: string;
37
39
  /** Flow ID origin; defaults per `defaultIdHost` (`FLOW_ID_HOST` on servers). */
38
40
  issuerUrl?: string;
41
+ /** Trusted server API origin; defaults to FLOW_ID_API_URL, then the public issuer. */
42
+ apiUrl?: string;
39
43
  /** Route path for `createSessionHandler`'s own path check. */
40
44
  path?: string;
41
45
  };
@@ -1,4 +1,5 @@
1
- import { createRemoteJWKSet, jwtVerify } from "jose";
1
+ import { createRemoteJWKSet, customFetch, jwtVerify } from "jose";
2
+ import { fetchIssuerApi, resolveIssuerApiUrl } from "./issuer-api";
2
3
  const jwksCache = new Map();
3
4
  const DEFAULT_ISSUER_URL = "https://id.flow.industries";
4
5
  /**
@@ -22,10 +23,14 @@ const DEFAULT_ISSUER_URL = "https://id.flow.industries";
22
23
  */
23
24
  export async function verifyFlowJWT(token, opts) {
24
25
  const issuerUrl = opts.issuerUrl ?? DEFAULT_ISSUER_URL;
25
- let jwks = jwksCache.get(issuerUrl);
26
+ const apiUrl = resolveIssuerApiUrl(issuerUrl, opts.apiUrl);
27
+ const cacheKey = `${issuerUrl}\n${apiUrl}`;
28
+ let jwks = jwksCache.get(cacheKey);
26
29
  if (!jwks) {
27
- jwks = createRemoteJWKSet(new URL(`${issuerUrl}/api/auth/jwks`));
28
- jwksCache.set(issuerUrl, jwks);
30
+ jwks = createRemoteJWKSet(new URL(`${apiUrl}/api/auth/jwks`), {
31
+ [customFetch]: fetchIssuerApi,
32
+ });
33
+ jwksCache.set(cacheKey, jwks);
29
34
  }
30
35
  const { payload } = await jwtVerify(token, jwks, {
31
36
  issuer: issuerUrl,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flow-industries/id",
3
- "version": "0.23.0",
3
+ "version": "0.23.1",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",