@flow-industries/id 0.22.1 → 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 +19 -8
- package/dist/sdk/browser-contract.d.ts +30 -0
- package/dist/sdk/browser-contract.js +19 -0
- package/dist/sdk/browser-session-route.d.ts +8 -0
- package/dist/sdk/browser-session-route.js +201 -0
- package/dist/sdk/client/create-flow.js +176 -166
- package/dist/sdk/client/flow-widget.js +1 -1
- package/dist/sdk/client/idb.d.ts +1 -0
- package/dist/sdk/client/idb.js +24 -0
- package/dist/sdk/client/open-profile.js +9 -1
- package/dist/sdk/client/profile-button.d.ts +1 -13
- package/dist/sdk/client/profile-button.js +114 -177
- package/dist/sdk/client/session.d.ts +1 -10
- package/dist/sdk/client/session.js +0 -37
- package/dist/sdk/client/static-flow.js +1 -0
- package/dist/sdk/issuer-api.d.ts +3 -0
- package/dist/sdk/issuer-api.js +28 -0
- package/dist/sdk/react/hooks.d.ts +1 -1
- package/dist/sdk/react/profile-button.d.ts +1 -12
- package/dist/sdk/react/profile-button.js +1 -12
- package/dist/sdk/server.js +6 -4
- package/dist/sdk/session-core.d.ts +2 -2
- package/dist/sdk/session-core.js +12 -11
- package/dist/sdk/session-route.js +114 -7
- package/dist/sdk/types/auth.d.ts +2 -0
- package/dist/sdk/types/dialog.d.ts +0 -3
- package/dist/sdk/types/index.d.ts +1 -1
- package/dist/sdk/types/landing.d.ts +1 -0
- package/dist/sdk/types/sdk.d.ts +7 -21
- package/dist/sdk/types/server.d.ts +10 -0
- package/dist/sdk/verify.js +9 -4
- package/dist/sdk/wagmi/index.js +0 -3
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -37,15 +37,24 @@ bun add @flow-industries/id wagmi viem @tanstack/react-query
|
|
|
37
37
|
|
|
38
38
|
## Documentation
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
- [Integration guide](docs/integration-guide.md) — server setup, canonical sign-in, React, and account navigation.
|
|
41
|
+
- [Guest sessions](docs/guest-sessions.md) — silent guest creation and preserving identity during signup.
|
|
42
|
+
- [Action sessions](docs/action-sessions.md) — activity timing and the inline action widget.
|
|
41
43
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
44
|
+
The Auth pages in the separate documentation repository are archived. Use the
|
|
45
|
+
guides here for the current SDK integration.
|
|
46
|
+
|
|
47
|
+
## Sign-in and account pages
|
|
48
|
+
|
|
49
|
+
The SDK starts sign-in through your app's first-party `/flow/session` handler
|
|
50
|
+
and navigates to Flow ID's `/authorize` page. Completion returns to the saved
|
|
51
|
+
app path; read the new session after navigation instead of continuing after
|
|
52
|
+
`await flow.login()`.
|
|
53
|
+
|
|
54
|
+
`ProfileButton` and `flow.openAccount()` open `/account` in a normal new tab.
|
|
55
|
+
The source app stays loaded and refreshes its session on refocus. Sign-in and
|
|
56
|
+
own-account management no longer use an embedded dialog. See the
|
|
57
|
+
[integration guide](docs/integration-guide.md) for server wiring and migration.
|
|
49
58
|
|
|
50
59
|
## Public profile overlays
|
|
51
60
|
|
|
@@ -134,3 +143,5 @@ The current OpenAPI artifact describes the service report boundary. The remainin
|
|
|
134
143
|
account, room management, study, settings, session and passkey HTTP operations
|
|
135
144
|
still need complete request/response descriptions and generated client coverage
|
|
136
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.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const browserSigningSchema: z.ZodObject<{
|
|
3
|
+
signature: z.ZodObject<{
|
|
4
|
+
r: z.ZodString;
|
|
5
|
+
s: z.ZodString;
|
|
6
|
+
}, z.core.$strip>;
|
|
7
|
+
metadata: z.ZodObject<{
|
|
8
|
+
authenticatorData: z.ZodString;
|
|
9
|
+
clientDataJSON: z.ZodString;
|
|
10
|
+
challengeIndex: z.ZodNumber;
|
|
11
|
+
typeIndex: z.ZodNumber;
|
|
12
|
+
}, z.core.$strip>;
|
|
13
|
+
}, z.core.$strip>;
|
|
14
|
+
export declare const browserCompletionSchema: z.ZodObject<{
|
|
15
|
+
loginId: z.ZodString;
|
|
16
|
+
userId: z.ZodString;
|
|
17
|
+
expires: z.ZodNumber;
|
|
18
|
+
webauthn: z.ZodOptional<z.ZodObject<{
|
|
19
|
+
signature: z.ZodObject<{
|
|
20
|
+
r: z.ZodString;
|
|
21
|
+
s: z.ZodString;
|
|
22
|
+
}, z.core.$strip>;
|
|
23
|
+
metadata: z.ZodObject<{
|
|
24
|
+
authenticatorData: z.ZodString;
|
|
25
|
+
clientDataJSON: z.ZodString;
|
|
26
|
+
challengeIndex: z.ZodNumber;
|
|
27
|
+
typeIndex: z.ZodNumber;
|
|
28
|
+
}, z.core.$strip>;
|
|
29
|
+
}, z.core.$strip>>;
|
|
30
|
+
}, z.core.$strip>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const browserSigningSchema = z.object({
|
|
3
|
+
signature: z.object({
|
|
4
|
+
r: z.string().regex(/^\d+$/),
|
|
5
|
+
s: z.string().regex(/^\d+$/),
|
|
6
|
+
}),
|
|
7
|
+
metadata: z.object({
|
|
8
|
+
authenticatorData: z.string().max(2048),
|
|
9
|
+
clientDataJSON: z.string().max(4096),
|
|
10
|
+
challengeIndex: z.number().int().nonnegative(),
|
|
11
|
+
typeIndex: z.number().int().nonnegative(),
|
|
12
|
+
}),
|
|
13
|
+
});
|
|
14
|
+
export const browserCompletionSchema = z.object({
|
|
15
|
+
loginId: z.string(),
|
|
16
|
+
userId: z.string(),
|
|
17
|
+
expires: z.number(),
|
|
18
|
+
webauthn: browserSigningSchema.optional(),
|
|
19
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare function safeReturnTo(value: string, audience: string, sessionPath?: string): string | null;
|
|
2
|
+
export declare function startBrowserSession(request: Request, audience: string, issuerUrl: string, body: {
|
|
3
|
+
action: "login" | "account";
|
|
4
|
+
returnTo?: string;
|
|
5
|
+
signUp?: boolean;
|
|
6
|
+
accessKeyHash?: string;
|
|
7
|
+
}, apiUrl?: string): Promise<Response>;
|
|
8
|
+
export declare function finishBrowserSession(request: Request, audience: string, issuerUrl: string, apiUrl?: string): Promise<Response>;
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { browserSigningSchema } from "./browser-contract";
|
|
3
|
+
import { clearCookieString, cookieNamesFor, parseCookieHeader, serializeCookie, } from "./cookies";
|
|
4
|
+
import { fetchIssuerApi, resolveIssuerApiUrl } from "./issuer-api";
|
|
5
|
+
import { claimsToUser, sessionCookies } from "./session-core";
|
|
6
|
+
import { verifyFlowJWT } from "./verify";
|
|
7
|
+
const opaquePattern = /^[A-Za-z0-9_-]{43}$/;
|
|
8
|
+
const pendingSchema = z.object({
|
|
9
|
+
state: z.string().regex(opaquePattern),
|
|
10
|
+
verifier: z.string().regex(opaquePattern),
|
|
11
|
+
returnTo: z.string(),
|
|
12
|
+
expires: z.number(),
|
|
13
|
+
});
|
|
14
|
+
const exchangeSchema = z.object({
|
|
15
|
+
jwt: z.string(),
|
|
16
|
+
refreshToken: z.string(),
|
|
17
|
+
webauthn: browserSigningSchema.optional(),
|
|
18
|
+
});
|
|
19
|
+
function base64Url(bytes) {
|
|
20
|
+
return btoa(String.fromCharCode(...bytes))
|
|
21
|
+
.replaceAll("+", "-")
|
|
22
|
+
.replaceAll("/", "_")
|
|
23
|
+
.replaceAll("=", "");
|
|
24
|
+
}
|
|
25
|
+
function secret() {
|
|
26
|
+
return base64Url(crypto.getRandomValues(new Uint8Array(32)));
|
|
27
|
+
}
|
|
28
|
+
async function pkce(value) {
|
|
29
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
|
30
|
+
return base64Url(new Uint8Array(digest));
|
|
31
|
+
}
|
|
32
|
+
function cookieName(audience, state) {
|
|
33
|
+
return `${cookieNamesFor(audience).refresh}.login_${state}`;
|
|
34
|
+
}
|
|
35
|
+
function reply(data, status = 200, cookies = []) {
|
|
36
|
+
const headers = new Headers({
|
|
37
|
+
"Content-Type": "application/json",
|
|
38
|
+
"Cache-Control": "no-store",
|
|
39
|
+
"Referrer-Policy": "no-referrer",
|
|
40
|
+
});
|
|
41
|
+
for (const cookie of cookies)
|
|
42
|
+
headers.append("Set-Cookie", cookie);
|
|
43
|
+
return new Response(JSON.stringify(data), { status, headers });
|
|
44
|
+
}
|
|
45
|
+
export function safeReturnTo(value, audience, sessionPath = "/flow/session") {
|
|
46
|
+
if (!value.startsWith("/") ||
|
|
47
|
+
value.startsWith("//") ||
|
|
48
|
+
value.includes("\\") ||
|
|
49
|
+
Array.from(value).some((character) => {
|
|
50
|
+
const code = character.charCodeAt(0);
|
|
51
|
+
return code < 32 || code === 127;
|
|
52
|
+
}))
|
|
53
|
+
return null;
|
|
54
|
+
try {
|
|
55
|
+
const url = new URL(value, audience);
|
|
56
|
+
if (url.origin !== audience || url.pathname === sessionPath)
|
|
57
|
+
return null;
|
|
58
|
+
return `${url.pathname}${url.search}${url.hash}`;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export async function startBrowserSession(request, audience, issuerUrl, body, apiUrl = resolveIssuerApiUrl(issuerUrl)) {
|
|
65
|
+
const cookies = parseCookieHeader(request.headers.get("Cookie"));
|
|
66
|
+
const refresh = cookies[cookieNamesFor(audience).refresh];
|
|
67
|
+
const headers = new Headers({ "Content-Type": "application/json" });
|
|
68
|
+
if (refresh)
|
|
69
|
+
headers.set("Authorization", `Bearer ${refresh}`);
|
|
70
|
+
for (const name of ["cf-connecting-ip", "x-forwarded-for", "user-agent"]) {
|
|
71
|
+
const value = request.headers.get(name);
|
|
72
|
+
if (value)
|
|
73
|
+
headers.set(name, value);
|
|
74
|
+
}
|
|
75
|
+
if (body.action === "account") {
|
|
76
|
+
if (!refresh)
|
|
77
|
+
return reply({ error: "signed_out" }, 401);
|
|
78
|
+
const response = await fetchIssuerApi(`${apiUrl}/api/auth/browser/account-handoff`, { method: "POST", headers, body: JSON.stringify({ audience }) });
|
|
79
|
+
if (!response.ok)
|
|
80
|
+
return reply({ error: "account_unavailable" }, response.status);
|
|
81
|
+
const { handoff } = z
|
|
82
|
+
.object({ handoff: z.string().regex(opaquePattern) })
|
|
83
|
+
.parse(await response.json());
|
|
84
|
+
return reply({ accountUrl: `${issuerUrl}/account#handoff=${handoff}` });
|
|
85
|
+
}
|
|
86
|
+
const returnTo = safeReturnTo(body.returnTo ?? "/", audience, new URL(request.url).pathname);
|
|
87
|
+
if (!returnTo)
|
|
88
|
+
return reply({ error: "invalid_return_destination" }, 400);
|
|
89
|
+
const state = secret();
|
|
90
|
+
const verifier = secret();
|
|
91
|
+
const callback = `${audience}${new URL(request.url).pathname}`;
|
|
92
|
+
const response = await fetchIssuerApi(`${apiUrl}/api/auth/browser/transaction`, {
|
|
93
|
+
method: "POST",
|
|
94
|
+
headers,
|
|
95
|
+
body: JSON.stringify({
|
|
96
|
+
audience,
|
|
97
|
+
callback,
|
|
98
|
+
state,
|
|
99
|
+
challenge: await pkce(verifier),
|
|
100
|
+
signUp: body.signUp ?? false,
|
|
101
|
+
accessKeyHash: body.accessKeyHash,
|
|
102
|
+
}),
|
|
103
|
+
});
|
|
104
|
+
if (!response.ok)
|
|
105
|
+
return reply({ error: "login_unavailable" }, response.status);
|
|
106
|
+
const { transaction } = z
|
|
107
|
+
.object({ transaction: z.string().regex(opaquePattern) })
|
|
108
|
+
.parse(await response.json());
|
|
109
|
+
const pending = btoa(encodeURIComponent(JSON.stringify({
|
|
110
|
+
state,
|
|
111
|
+
verifier,
|
|
112
|
+
returnTo,
|
|
113
|
+
expires: Date.now() + 600_000,
|
|
114
|
+
})));
|
|
115
|
+
return reply({
|
|
116
|
+
loginId: state,
|
|
117
|
+
authorizeUrl: `${issuerUrl}/authorize?transaction=${transaction}`,
|
|
118
|
+
}, 200, [
|
|
119
|
+
serializeCookie(cookieName(audience, state), pending, {
|
|
120
|
+
maxAge: 600,
|
|
121
|
+
secure: audience.startsWith("https:"),
|
|
122
|
+
}),
|
|
123
|
+
]);
|
|
124
|
+
}
|
|
125
|
+
export async function finishBrowserSession(request, audience, issuerUrl, apiUrl = resolveIssuerApiUrl(issuerUrl)) {
|
|
126
|
+
const url = new URL(request.url);
|
|
127
|
+
const state = url.searchParams.get("flow_state") ?? "";
|
|
128
|
+
if (!opaquePattern.test(state))
|
|
129
|
+
return reply({ error: "invalid_login_state" }, 400);
|
|
130
|
+
const cookie = cookieName(audience, state);
|
|
131
|
+
const raw = parseCookieHeader(request.headers.get("Cookie"))[cookie];
|
|
132
|
+
let pending;
|
|
133
|
+
try {
|
|
134
|
+
pending = pendingSchema.parse(JSON.parse(decodeURIComponent(atob(raw ?? ""))));
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return reply({ error: "login_expired" }, 400);
|
|
138
|
+
}
|
|
139
|
+
const returnTo = safeReturnTo(pending.returnTo, audience, new URL(request.url).pathname);
|
|
140
|
+
if (pending.state !== state || pending.expires <= Date.now() || !returnTo)
|
|
141
|
+
return reply({ error: "invalid_login_state" }, 400);
|
|
142
|
+
const clear = clearCookieString(cookie, {
|
|
143
|
+
secure: audience.startsWith("https:"),
|
|
144
|
+
});
|
|
145
|
+
const headers = new Headers({
|
|
146
|
+
"Cache-Control": "no-store",
|
|
147
|
+
"Referrer-Policy": "no-referrer",
|
|
148
|
+
Location: returnTo,
|
|
149
|
+
});
|
|
150
|
+
if (url.searchParams.get("flow_error") === "cancelled") {
|
|
151
|
+
headers.append("Set-Cookie", clear);
|
|
152
|
+
return new Response(null, { status: 303, headers });
|
|
153
|
+
}
|
|
154
|
+
const code = url.searchParams.get("flow_code") ?? "";
|
|
155
|
+
if (!opaquePattern.test(code))
|
|
156
|
+
return reply({ error: "invalid_login_code" }, 400);
|
|
157
|
+
try {
|
|
158
|
+
const result = await fetchIssuerApi(`${apiUrl}/api/auth/browser/token`, {
|
|
159
|
+
method: "POST",
|
|
160
|
+
headers: { "Content-Type": "application/json" },
|
|
161
|
+
body: JSON.stringify({
|
|
162
|
+
code,
|
|
163
|
+
verifier: pending.verifier,
|
|
164
|
+
audience,
|
|
165
|
+
callback: `${audience}${url.pathname}`,
|
|
166
|
+
}),
|
|
167
|
+
});
|
|
168
|
+
if (!result.ok)
|
|
169
|
+
return reply({
|
|
170
|
+
error: "login_exchange_failed",
|
|
171
|
+
message: "Return to your app and start sign-in again.",
|
|
172
|
+
}, 400, [clear]);
|
|
173
|
+
const session = exchangeSchema.parse(await result.json());
|
|
174
|
+
const claims = await verifyFlowJWT(session.jwt, {
|
|
175
|
+
audience,
|
|
176
|
+
issuerUrl,
|
|
177
|
+
apiUrl,
|
|
178
|
+
});
|
|
179
|
+
if (!claimsToUser(claims).id)
|
|
180
|
+
throw new Error("Invalid session");
|
|
181
|
+
for (const value of sessionCookies(audience, session.jwt, session.refreshToken))
|
|
182
|
+
headers.append("Set-Cookie", value);
|
|
183
|
+
if (session.webauthn) {
|
|
184
|
+
const completion = btoa(encodeURIComponent(JSON.stringify({
|
|
185
|
+
loginId: state,
|
|
186
|
+
userId: claims.sub,
|
|
187
|
+
expires: Date.now() + 60_000,
|
|
188
|
+
webauthn: session.webauthn,
|
|
189
|
+
})));
|
|
190
|
+
headers.append("Set-Cookie", serializeCookie(`${cookieNamesFor(audience).refresh}.completion`, completion, { maxAge: 60, secure: audience.startsWith("https:") }));
|
|
191
|
+
}
|
|
192
|
+
headers.append("Set-Cookie", clear);
|
|
193
|
+
return new Response(null, { status: 303, headers });
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return reply({
|
|
197
|
+
error: "login_exchange_unavailable",
|
|
198
|
+
message: "Return to your app and start sign-in again.",
|
|
199
|
+
}, 502, [clear]);
|
|
200
|
+
}
|
|
201
|
+
}
|