@flow-industries/id 0.23.4 → 0.24.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
@@ -21,6 +21,82 @@ rotated so local session creation keeps working after environment changes.
21
21
  It also refuses to start when migrations fail instead of serving against a
22
22
  partially migrated schema.
23
23
 
24
+ ### AT Protocol identity
25
+
26
+ Bluesky and other AT Protocol accounts can sign in to Flow ID or connect to an
27
+ existing account. Flow user IDs remain stable; the provider DID identifies the
28
+ connection. New accounts do not need an email, passkey, or wallet to enroll.
29
+
30
+ Set `ATPROTO_PUBLIC_URL`, `ATPROTO_PRIVATE_KEY` (an ES256 private JWK with a stable
31
+ `kid`), and `ATPROTO_STORAGE_KEY` (a base64-encoded 32-byte encryption key) to
32
+ enable the integration. Leave all three unset to disable it. Production uses
33
+ HTTPS client metadata at `/api/auth/atproto/client-metadata.json` and public keys
34
+ at `/api/auth/atproto/jwks.json`. Provider tokens and DPoP keys are encrypted in
35
+ Postgres and stay on the server. Back up the storage key with the database;
36
+ changing it requires re-encrypting stored credentials.
37
+
38
+ For local OAuth, set `ATPROTO_PUBLIC_URL=http://127.0.0.1:8183` and launch
39
+ `./dev.sh 8180`. The hosted sign-in page uses `http://localhost:8183` and the
40
+ launcher sets the WebAuthn relying party to `localhost`. Use the printed URLs;
41
+ existing passkeys remain bound to their original relying party.
42
+
43
+ Without an email provider configured, the local launcher also prints sign-in
44
+ codes in the browser console. This requires development mode, the launcher's
45
+ `FLOW_DEV_OTP_CONSOLE=1` opt-in, and a loopback issuer; the API then binds only to
46
+ `127.0.0.1`. Production responses never include these codes.
47
+
48
+ AT Protocol requires an IP loopback callback URI. The development landing server
49
+ redirects loopback GET requests to its fixed localhost origin before handling
50
+ them, retaining the callback query so OAuth resumes with the same browser cookies
51
+ and session storage as sign-in. The SDK still exchanges the code using the
52
+ registered IP callback URI. If that port block is occupied, update
53
+ `ATPROTO_PUBLIC_URL` to the selected landing port and restart. Production clients
54
+ use HTTPS metadata and authenticate with the configured signing key.
55
+
56
+ Provider sign-in resumes the bound `/authorize` transaction. Flow installs a
57
+ first-party session, then returns the existing one-use PKCE code to the app.
58
+ Account linking returns to `/account`; no provider or Flow tokens travel in
59
+ redirect URLs or window messages.
60
+
61
+ The account connection view reads saved identities and credentials locally. It
62
+ does not refresh provider tokens or resolve handles on page load; reconnect
63
+ performs a new provider authorization when access needs to be restored.
64
+
65
+ ### Connected accounts
66
+
67
+ Better Auth owns the shared `account` model. External identities are keyed by
68
+ `providerId` and `accountId`, and link to the canonical Flow `userId`. For AT
69
+ Protocol these values are `atproto` and the verified DID. Handles are display
70
+ metadata, never an identity key. A Flow account can connect different providers
71
+ without adding provider-specific identity tables.
72
+ Provider IDs identify a trusted provider configuration: separate OIDC issuers
73
+ must use separate IDs, even if both run the same identity-server software.
74
+
75
+ Provider credentials and temporary authorization state live in the encrypted,
76
+ provider-scoped `oauth_session` and `oauth_state` stores. The AT SDK uses an
77
+ adapter over those stores; its DPoP and discovery behavior stays within the AT
78
+ integration. Provider tokens never become Flow session tokens.
79
+
80
+ Better Auth supports built-in social providers and its Generic OAuth plugin.
81
+ Flow currently uses version 1.4.17, whose default OAuth enrollment requires an
82
+ email and creates its own sessions. A new provider must supply a verified stable
83
+ subject to Flow's shared account enrollment, then use Flow's audience-bound
84
+ session issuance. Register usable sign-in providers explicitly; arbitrary
85
+ account rows do not count as recovery methods. No new identity table is needed.
86
+
87
+ Automatic email-based account linking is disabled. Disconnect through Flow's
88
+ account controls so credential cleanup and last-sign-in-method checks run in
89
+ one transaction; the stock Better Auth unlink endpoint is blocked. Its account
90
+ listing remains available and excludes credentials.
91
+
92
+ Migration `0040` preserves existing AT links and encrypted sessions, then removes
93
+ the earlier AT-only tables. It invalidates in-flight authorization state, so
94
+ unfinished attempts must restart. Deploy the complete revised Auth stack before
95
+ enabling AT production configuration. After this migration, the intermediate
96
+ AT-only builds from PRs #170–#172 are not rollback targets; use the pre-AT main
97
+ build or a compatible forward fix. Existing pre-AT Flow user/session writes
98
+ remain supported.
99
+
24
100
  **npm:** [`@flow-industries/id`](https://www.npmjs.com/package/@flow-industries/id)
25
101
 
26
102
  ## Installation
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ export declare const BROWSER_FLOW_TTL_MS: number;
2
3
  export declare const browserSigningSchema: z.ZodObject<{
3
4
  signature: z.ZodObject<{
4
5
  r: z.ZodString;
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ export const BROWSER_FLOW_TTL_MS = 24 * 60 * 60 * 1000;
2
3
  export const browserSigningSchema = z.object({
3
4
  signature: z.object({
4
5
  r: z.string().regex(/^\d+$/),
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { browserSigningSchema } from "./browser-contract";
2
+ import { BROWSER_FLOW_TTL_MS, browserSigningSchema } from "./browser-contract";
3
3
  import { clearCookieString, cookieNamesFor, parseCookieHeader, serializeCookie, } from "./cookies";
4
4
  import { fetchIssuerApi, resolveIssuerApiUrl } from "./issuer-api";
5
5
  import { claimsToUser, sessionCookies } from "./session-core";
@@ -110,14 +110,14 @@ export async function startBrowserSession(request, audience, issuerUrl, body, ap
110
110
  state,
111
111
  verifier,
112
112
  returnTo,
113
- expires: Date.now() + 600_000,
113
+ expires: Date.now() + BROWSER_FLOW_TTL_MS,
114
114
  })));
115
115
  return reply({
116
116
  loginId: state,
117
117
  authorizeUrl: `${issuerUrl}/authorize?transaction=${transaction}`,
118
118
  }, 200, [
119
119
  serializeCookie(cookieName(audience, state), pending, {
120
- maxAge: 600,
120
+ maxAge: BROWSER_FLOW_TTL_MS / 1000,
121
121
  secure: audience.startsWith("https:"),
122
122
  }),
123
123
  ]);
@@ -0,0 +1,5 @@
1
+ import type { AtprotoCompletion, AtprotoStartRequest, AtprotoStatus } from "../types";
2
+ export declare function getAtprotoStatus(signal?: AbortSignal): Promise<AtprotoStatus>;
3
+ export declare function unlinkAtproto(expectedUserId: string): Promise<void>;
4
+ export declare function connectAtproto(request: AtprotoStartRequest, signal: AbortSignal): Promise<void>;
5
+ export declare function completeAtproto(): Promise<AtprotoCompletion>;
@@ -0,0 +1,49 @@
1
+ async function readResponse(response) {
2
+ const body = await response.json();
3
+ if (!response.ok)
4
+ throw new Error(body.message ??
5
+ body.error ??
6
+ "Could not connect your AT Protocol account.");
7
+ // SAFETY: the caller names the payload returned by the same-origin Flow endpoint.
8
+ return body;
9
+ }
10
+ export async function getAtprotoStatus(signal) {
11
+ return readResponse(await fetch("/api/auth/atproto/status", { credentials: "include", signal }));
12
+ }
13
+ export async function unlinkAtproto(expectedUserId) {
14
+ await readResponse(await fetch("/api/auth/atproto/unlink", {
15
+ method: "POST",
16
+ credentials: "include",
17
+ headers: { "Content-Type": "application/json" },
18
+ body: JSON.stringify({ expectedUserId }),
19
+ }));
20
+ }
21
+ const completionKey = "flow.atproto.completion";
22
+ export async function connectAtproto(request, signal) {
23
+ signal.throwIfAborted();
24
+ const result = await readResponse(await fetch("/api/auth/atproto/start", {
25
+ method: "POST",
26
+ credentials: "include",
27
+ headers: { "Content-Type": "application/json" },
28
+ body: JSON.stringify(request),
29
+ signal,
30
+ }));
31
+ signal.throwIfAborted();
32
+ sessionStorage.setItem(completionKey, result.completionToken);
33
+ window.location.assign(result.url);
34
+ }
35
+ let completion = null;
36
+ export function completeAtproto() {
37
+ if (completion)
38
+ return completion;
39
+ const completionToken = sessionStorage.getItem(completionKey);
40
+ if (!completionToken)
41
+ return Promise.reject(new Error("Sign-in expired. Please try again."));
42
+ completion = fetch("/api/auth/atproto/complete", {
43
+ method: "POST",
44
+ credentials: "include",
45
+ headers: { "Content-Type": "application/json" },
46
+ body: JSON.stringify({ completionToken }),
47
+ }).then((readResponse));
48
+ return completion;
49
+ }
@@ -1,3 +1,4 @@
1
+ import { BROWSER_FLOW_TTL_MS } from "../browser-contract";
1
2
  import { DEFAULT_SESSION_PATH, isLocalHostname, resolveIdHost, } from "../id-host";
2
3
  import { isExpiring } from "../token-expiry";
3
4
  import { createDialogHost } from "./dialog-host";
@@ -385,7 +386,7 @@ export function createFlow(options = {}) {
385
386
  if (preparation)
386
387
  await idb.set(`flow.pendingAccessKey.${result.loginId}`, {
387
388
  preparation,
388
- expires: Date.now() + 600_000,
389
+ expires: Date.now() + BROWSER_FLOW_TTL_MS,
389
390
  });
390
391
  window.location.assign(result.authorizeUrl);
391
392
  return new Promise((_resolve, reject) => {
@@ -0,0 +1,11 @@
1
+ import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core";
2
+ import type * as schema from "./schema";
3
+ /**
4
+ * Any pg-dialect drizzle handle over the app schema — postgres-js in the
5
+ * server, PGlite in the tests.
6
+ *
7
+ * Core logic takes this as a parameter instead of importing the `db` singleton,
8
+ * which requires DATABASE_URL at import time and so cannot be exercised without
9
+ * a Postgres server. Type-only, so importing it never pulls the singleton in.
10
+ */
11
+ export type AppDb = PgDatabase<PgQueryResultHKT, typeof schema>;
File without changes