@flow-industries/id 0.22.1 → 0.23.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/README.md +18 -9
- 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 +196 -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/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/session-route.js +101 -4
- 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 +6 -0
- 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
|
-
|
|
41
|
-
|
|
42
|
-
- [
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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.
|
|
43
|
+
|
|
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
|
|
|
@@ -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
|
+
}): Promise<Response>;
|
|
8
|
+
export declare function finishBrowserSession(request: Request, audience: string, issuerUrl: string): Promise<Response>;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { browserSigningSchema } from "./browser-contract";
|
|
3
|
+
import { clearCookieString, cookieNamesFor, parseCookieHeader, serializeCookie, } from "./cookies";
|
|
4
|
+
import { claimsToUser, sessionCookies } from "./session-core";
|
|
5
|
+
import { verifyFlowJWT } from "./verify";
|
|
6
|
+
const opaquePattern = /^[A-Za-z0-9_-]{43}$/;
|
|
7
|
+
const pendingSchema = z.object({
|
|
8
|
+
state: z.string().regex(opaquePattern),
|
|
9
|
+
verifier: z.string().regex(opaquePattern),
|
|
10
|
+
returnTo: z.string(),
|
|
11
|
+
expires: z.number(),
|
|
12
|
+
});
|
|
13
|
+
const exchangeSchema = z.object({
|
|
14
|
+
jwt: z.string(),
|
|
15
|
+
refreshToken: z.string(),
|
|
16
|
+
webauthn: browserSigningSchema.optional(),
|
|
17
|
+
});
|
|
18
|
+
function base64Url(bytes) {
|
|
19
|
+
return btoa(String.fromCharCode(...bytes))
|
|
20
|
+
.replaceAll("+", "-")
|
|
21
|
+
.replaceAll("/", "_")
|
|
22
|
+
.replaceAll("=", "");
|
|
23
|
+
}
|
|
24
|
+
function secret() {
|
|
25
|
+
return base64Url(crypto.getRandomValues(new Uint8Array(32)));
|
|
26
|
+
}
|
|
27
|
+
async function pkce(value) {
|
|
28
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
|
29
|
+
return base64Url(new Uint8Array(digest));
|
|
30
|
+
}
|
|
31
|
+
function cookieName(audience, state) {
|
|
32
|
+
return `${cookieNamesFor(audience).refresh}.login_${state}`;
|
|
33
|
+
}
|
|
34
|
+
function reply(data, status = 200, cookies = []) {
|
|
35
|
+
const headers = new Headers({
|
|
36
|
+
"Content-Type": "application/json",
|
|
37
|
+
"Cache-Control": "no-store",
|
|
38
|
+
"Referrer-Policy": "no-referrer",
|
|
39
|
+
});
|
|
40
|
+
for (const cookie of cookies)
|
|
41
|
+
headers.append("Set-Cookie", cookie);
|
|
42
|
+
return new Response(JSON.stringify(data), { status, headers });
|
|
43
|
+
}
|
|
44
|
+
export function safeReturnTo(value, audience, sessionPath = "/flow/session") {
|
|
45
|
+
if (!value.startsWith("/") ||
|
|
46
|
+
value.startsWith("//") ||
|
|
47
|
+
value.includes("\\") ||
|
|
48
|
+
Array.from(value).some((character) => {
|
|
49
|
+
const code = character.charCodeAt(0);
|
|
50
|
+
return code < 32 || code === 127;
|
|
51
|
+
}))
|
|
52
|
+
return null;
|
|
53
|
+
try {
|
|
54
|
+
const url = new URL(value, audience);
|
|
55
|
+
if (url.origin !== audience || url.pathname === sessionPath)
|
|
56
|
+
return null;
|
|
57
|
+
return `${url.pathname}${url.search}${url.hash}`;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export async function startBrowserSession(request, audience, issuerUrl, body) {
|
|
64
|
+
const cookies = parseCookieHeader(request.headers.get("Cookie"));
|
|
65
|
+
const refresh = cookies[cookieNamesFor(audience).refresh];
|
|
66
|
+
const headers = new Headers({ "Content-Type": "application/json" });
|
|
67
|
+
if (refresh)
|
|
68
|
+
headers.set("Authorization", `Bearer ${refresh}`);
|
|
69
|
+
for (const name of ["cf-connecting-ip", "x-forwarded-for", "user-agent"]) {
|
|
70
|
+
const value = request.headers.get(name);
|
|
71
|
+
if (value)
|
|
72
|
+
headers.set(name, value);
|
|
73
|
+
}
|
|
74
|
+
if (body.action === "account") {
|
|
75
|
+
if (!refresh)
|
|
76
|
+
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
|
+
if (!response.ok)
|
|
79
|
+
return reply({ error: "account_unavailable" }, response.status);
|
|
80
|
+
const { handoff } = z
|
|
81
|
+
.object({ handoff: z.string().regex(opaquePattern) })
|
|
82
|
+
.parse(await response.json());
|
|
83
|
+
return reply({ accountUrl: `${issuerUrl}/account#handoff=${handoff}` });
|
|
84
|
+
}
|
|
85
|
+
const returnTo = safeReturnTo(body.returnTo ?? "/", audience, new URL(request.url).pathname);
|
|
86
|
+
if (!returnTo)
|
|
87
|
+
return reply({ error: "invalid_return_destination" }, 400);
|
|
88
|
+
const state = secret();
|
|
89
|
+
const verifier = secret();
|
|
90
|
+
const callback = `${audience}${new URL(request.url).pathname}`;
|
|
91
|
+
const response = await fetch(`${issuerUrl}/api/auth/browser/transaction`, {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers,
|
|
94
|
+
body: JSON.stringify({
|
|
95
|
+
audience,
|
|
96
|
+
callback,
|
|
97
|
+
state,
|
|
98
|
+
challenge: await pkce(verifier),
|
|
99
|
+
signUp: body.signUp ?? false,
|
|
100
|
+
accessKeyHash: body.accessKeyHash,
|
|
101
|
+
}),
|
|
102
|
+
});
|
|
103
|
+
if (!response.ok)
|
|
104
|
+
return reply({ error: "login_unavailable" }, response.status);
|
|
105
|
+
const { transaction } = z
|
|
106
|
+
.object({ transaction: z.string().regex(opaquePattern) })
|
|
107
|
+
.parse(await response.json());
|
|
108
|
+
const pending = btoa(encodeURIComponent(JSON.stringify({
|
|
109
|
+
state,
|
|
110
|
+
verifier,
|
|
111
|
+
returnTo,
|
|
112
|
+
expires: Date.now() + 600_000,
|
|
113
|
+
})));
|
|
114
|
+
return reply({
|
|
115
|
+
loginId: state,
|
|
116
|
+
authorizeUrl: `${issuerUrl}/authorize?transaction=${transaction}`,
|
|
117
|
+
}, 200, [
|
|
118
|
+
serializeCookie(cookieName(audience, state), pending, {
|
|
119
|
+
maxAge: 600,
|
|
120
|
+
secure: audience.startsWith("https:"),
|
|
121
|
+
}),
|
|
122
|
+
]);
|
|
123
|
+
}
|
|
124
|
+
export async function finishBrowserSession(request, audience, issuerUrl) {
|
|
125
|
+
const url = new URL(request.url);
|
|
126
|
+
const state = url.searchParams.get("flow_state") ?? "";
|
|
127
|
+
if (!opaquePattern.test(state))
|
|
128
|
+
return reply({ error: "invalid_login_state" }, 400);
|
|
129
|
+
const cookie = cookieName(audience, state);
|
|
130
|
+
const raw = parseCookieHeader(request.headers.get("Cookie"))[cookie];
|
|
131
|
+
let pending;
|
|
132
|
+
try {
|
|
133
|
+
pending = pendingSchema.parse(JSON.parse(decodeURIComponent(atob(raw ?? ""))));
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return reply({ error: "login_expired" }, 400);
|
|
137
|
+
}
|
|
138
|
+
const returnTo = safeReturnTo(pending.returnTo, audience, new URL(request.url).pathname);
|
|
139
|
+
if (pending.state !== state || pending.expires <= Date.now() || !returnTo)
|
|
140
|
+
return reply({ error: "invalid_login_state" }, 400);
|
|
141
|
+
const clear = clearCookieString(cookie, {
|
|
142
|
+
secure: audience.startsWith("https:"),
|
|
143
|
+
});
|
|
144
|
+
const headers = new Headers({
|
|
145
|
+
"Cache-Control": "no-store",
|
|
146
|
+
"Referrer-Policy": "no-referrer",
|
|
147
|
+
Location: returnTo,
|
|
148
|
+
});
|
|
149
|
+
if (url.searchParams.get("flow_error") === "cancelled") {
|
|
150
|
+
headers.append("Set-Cookie", clear);
|
|
151
|
+
return new Response(null, { status: 303, headers });
|
|
152
|
+
}
|
|
153
|
+
const code = url.searchParams.get("flow_code") ?? "";
|
|
154
|
+
if (!opaquePattern.test(code))
|
|
155
|
+
return reply({ error: "invalid_login_code" }, 400);
|
|
156
|
+
try {
|
|
157
|
+
const result = await fetch(`${issuerUrl}/api/auth/browser/token`, {
|
|
158
|
+
method: "POST",
|
|
159
|
+
headers: { "Content-Type": "application/json" },
|
|
160
|
+
body: JSON.stringify({
|
|
161
|
+
code,
|
|
162
|
+
verifier: pending.verifier,
|
|
163
|
+
audience,
|
|
164
|
+
callback: `${audience}${url.pathname}`,
|
|
165
|
+
}),
|
|
166
|
+
});
|
|
167
|
+
if (!result.ok)
|
|
168
|
+
return reply({
|
|
169
|
+
error: "login_exchange_failed",
|
|
170
|
+
message: "Return to your app and start sign-in again.",
|
|
171
|
+
}, 400, [clear]);
|
|
172
|
+
const session = exchangeSchema.parse(await result.json());
|
|
173
|
+
const claims = await verifyFlowJWT(session.jwt, { audience, issuerUrl });
|
|
174
|
+
if (!claimsToUser(claims).id)
|
|
175
|
+
throw new Error("Invalid session");
|
|
176
|
+
for (const value of sessionCookies(audience, session.jwt, session.refreshToken))
|
|
177
|
+
headers.append("Set-Cookie", value);
|
|
178
|
+
if (session.webauthn) {
|
|
179
|
+
const completion = btoa(encodeURIComponent(JSON.stringify({
|
|
180
|
+
loginId: state,
|
|
181
|
+
userId: claims.sub,
|
|
182
|
+
expires: Date.now() + 60_000,
|
|
183
|
+
webauthn: session.webauthn,
|
|
184
|
+
})));
|
|
185
|
+
headers.append("Set-Cookie", serializeCookie(`${cookieNamesFor(audience).refresh}.completion`, completion, { maxAge: 60, secure: audience.startsWith("https:") }));
|
|
186
|
+
}
|
|
187
|
+
headers.append("Set-Cookie", clear);
|
|
188
|
+
return new Response(null, { status: 303, headers });
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return reply({
|
|
192
|
+
error: "login_exchange_unavailable",
|
|
193
|
+
message: "Return to your app and start sign-in again.",
|
|
194
|
+
}, 502, [clear]);
|
|
195
|
+
}
|
|
196
|
+
}
|