@stapel/auth-react 1.0.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.
Files changed (191) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/LICENSE +21 -0
  3. package/MODULE.md +147 -0
  4. package/README.md +116 -0
  5. package/dist/api/authApi.d.ts +68 -0
  6. package/dist/api/authApi.d.ts.map +1 -0
  7. package/dist/api/authApi.js +90 -0
  8. package/dist/api/authApi.js.map +1 -0
  9. package/dist/api/types.d.ts +238 -0
  10. package/dist/api/types.d.ts.map +1 -0
  11. package/dist/api/types.js +14 -0
  12. package/dist/api/types.js.map +1 -0
  13. package/dist/api/urls.d.ts +41 -0
  14. package/dist/api/urls.d.ts.map +1 -0
  15. package/dist/api/urls.js +86 -0
  16. package/dist/api/urls.js.map +1 -0
  17. package/dist/flows/anonymousFlow.d.ts +33 -0
  18. package/dist/flows/anonymousFlow.d.ts.map +1 -0
  19. package/dist/flows/anonymousFlow.js +26 -0
  20. package/dist/flows/anonymousFlow.js.map +1 -0
  21. package/dist/flows/authenticatorChangeFlow.d.ts +67 -0
  22. package/dist/flows/authenticatorChangeFlow.d.ts.map +1 -0
  23. package/dist/flows/authenticatorChangeFlow.js +79 -0
  24. package/dist/flows/authenticatorChangeFlow.js.map +1 -0
  25. package/dist/flows/createFlowMachine.d.ts +55 -0
  26. package/dist/flows/createFlowMachine.d.ts.map +1 -0
  27. package/dist/flows/createFlowMachine.js +56 -0
  28. package/dist/flows/createFlowMachine.js.map +1 -0
  29. package/dist/flows/errors.d.ts +15 -0
  30. package/dist/flows/errors.d.ts.map +1 -0
  31. package/dist/flows/errors.js +17 -0
  32. package/dist/flows/errors.js.map +1 -0
  33. package/dist/flows/magicLinkFlow.d.ts +41 -0
  34. package/dist/flows/magicLinkFlow.d.ts.map +1 -0
  35. package/dist/flows/magicLinkFlow.js +29 -0
  36. package/dist/flows/magicLinkFlow.js.map +1 -0
  37. package/dist/flows/oauthFlow.d.ts +58 -0
  38. package/dist/flows/oauthFlow.d.ts.map +1 -0
  39. package/dist/flows/oauthFlow.js +53 -0
  40. package/dist/flows/oauthFlow.js.map +1 -0
  41. package/dist/flows/otpFlow.d.ts +74 -0
  42. package/dist/flows/otpFlow.d.ts.map +1 -0
  43. package/dist/flows/otpFlow.js +68 -0
  44. package/dist/flows/otpFlow.js.map +1 -0
  45. package/dist/flows/passkeyFlow.d.ts +75 -0
  46. package/dist/flows/passkeyFlow.d.ts.map +1 -0
  47. package/dist/flows/passkeyFlow.js +100 -0
  48. package/dist/flows/passkeyFlow.js.map +1 -0
  49. package/dist/flows/passwordChangeFlow.d.ts +53 -0
  50. package/dist/flows/passwordChangeFlow.d.ts.map +1 -0
  51. package/dist/flows/passwordChangeFlow.js +51 -0
  52. package/dist/flows/passwordChangeFlow.js.map +1 -0
  53. package/dist/flows/passwordLoginFlow.d.ts +62 -0
  54. package/dist/flows/passwordLoginFlow.d.ts.map +1 -0
  55. package/dist/flows/passwordLoginFlow.js +55 -0
  56. package/dist/flows/passwordLoginFlow.js.map +1 -0
  57. package/dist/flows/passwordResetFlow.d.ts +56 -0
  58. package/dist/flows/passwordResetFlow.d.ts.map +1 -0
  59. package/dist/flows/passwordResetFlow.js +57 -0
  60. package/dist/flows/passwordResetFlow.js.map +1 -0
  61. package/dist/flows/qrLoginFlow.d.ts +55 -0
  62. package/dist/flows/qrLoginFlow.d.ts.map +1 -0
  63. package/dist/flows/qrLoginFlow.js +91 -0
  64. package/dist/flows/qrLoginFlow.js.map +1 -0
  65. package/dist/flows/ssoFlow.d.ts +46 -0
  66. package/dist/flows/ssoFlow.d.ts.map +1 -0
  67. package/dist/flows/ssoFlow.js +34 -0
  68. package/dist/flows/ssoFlow.js.map +1 -0
  69. package/dist/flows/totpSetupFlow.d.ts +49 -0
  70. package/dist/flows/totpSetupFlow.d.ts.map +1 -0
  71. package/dist/flows/totpSetupFlow.js +47 -0
  72. package/dist/flows/totpSetupFlow.js.map +1 -0
  73. package/dist/flows/useFlow.d.ts +9 -0
  74. package/dist/flows/useFlow.d.ts.map +1 -0
  75. package/dist/flows/useFlow.js +11 -0
  76. package/dist/flows/useFlow.js.map +1 -0
  77. package/dist/flows/verificationFlow.d.ts +108 -0
  78. package/dist/flows/verificationFlow.d.ts.map +1 -0
  79. package/dist/flows/verificationFlow.js +195 -0
  80. package/dist/flows/verificationFlow.js.map +1 -0
  81. package/dist/headless/AuthProvider.d.ts +18 -0
  82. package/dist/headless/AuthProvider.d.ts.map +1 -0
  83. package/dist/headless/AuthProvider.js +22 -0
  84. package/dist/headless/AuthProvider.js.map +1 -0
  85. package/dist/headless/Passkey.d.ts +31 -0
  86. package/dist/headless/Passkey.d.ts.map +1 -0
  87. package/dist/headless/Passkey.js +51 -0
  88. package/dist/headless/Passkey.js.map +1 -0
  89. package/dist/headless/PasswordChange.d.ts +20 -0
  90. package/dist/headless/PasswordChange.d.ts.map +1 -0
  91. package/dist/headless/PasswordChange.js +30 -0
  92. package/dist/headless/PasswordChange.js.map +1 -0
  93. package/dist/headless/PasswordLogin.d.ts +17 -0
  94. package/dist/headless/PasswordLogin.d.ts.map +1 -0
  95. package/dist/headless/PasswordLogin.js +31 -0
  96. package/dist/headless/PasswordLogin.js.map +1 -0
  97. package/dist/headless/PasswordReset.d.ts +19 -0
  98. package/dist/headless/PasswordReset.d.ts.map +1 -0
  99. package/dist/headless/PasswordReset.js +34 -0
  100. package/dist/headless/PasswordReset.js.map +1 -0
  101. package/dist/headless/PasswordlessLogin.d.ts +28 -0
  102. package/dist/headless/PasswordlessLogin.d.ts.map +1 -0
  103. package/dist/headless/PasswordlessLogin.js +42 -0
  104. package/dist/headless/PasswordlessLogin.js.map +1 -0
  105. package/dist/headless/QrLogin.d.ts +19 -0
  106. package/dist/headless/QrLogin.d.ts.map +1 -0
  107. package/dist/headless/QrLogin.js +32 -0
  108. package/dist/headless/QrLogin.js.map +1 -0
  109. package/dist/headless/TotpSetup.d.ts +17 -0
  110. package/dist/headless/TotpSetup.d.ts.map +1 -0
  111. package/dist/headless/TotpSetup.js +26 -0
  112. package/dist/headless/TotpSetup.js.map +1 -0
  113. package/dist/headless/VerificationChallenge.d.ts +37 -0
  114. package/dist/headless/VerificationChallenge.d.ts.map +1 -0
  115. package/dist/headless/VerificationChallenge.js +40 -0
  116. package/dist/headless/VerificationChallenge.js.map +1 -0
  117. package/dist/headless/misc.d.ts +47 -0
  118. package/dist/headless/misc.d.ts.map +1 -0
  119. package/dist/headless/misc.js +84 -0
  120. package/dist/headless/misc.js.map +1 -0
  121. package/dist/i18n/keys.d.ts +34 -0
  122. package/dist/i18n/keys.d.ts.map +1 -0
  123. package/dist/i18n/keys.js +83 -0
  124. package/dist/i18n/keys.js.map +1 -0
  125. package/dist/index.d.ts +73 -0
  126. package/dist/index.d.ts.map +1 -0
  127. package/dist/index.js +48 -0
  128. package/dist/index.js.map +1 -0
  129. package/dist/model/context.d.ts +22 -0
  130. package/dist/model/context.d.ts.map +1 -0
  131. package/dist/model/context.js +34 -0
  132. package/dist/model/context.js.map +1 -0
  133. package/dist/model/mutations.d.ts +28 -0
  134. package/dist/model/mutations.d.ts.map +1 -0
  135. package/dist/model/mutations.js +108 -0
  136. package/dist/model/mutations.js.map +1 -0
  137. package/dist/model/queries.d.ts +30 -0
  138. package/dist/model/queries.d.ts.map +1 -0
  139. package/dist/model/queries.js +87 -0
  140. package/dist/model/queries.js.map +1 -0
  141. package/dist/model/queryKeys.d.ts +13 -0
  142. package/dist/model/queryKeys.d.ts.map +1 -0
  143. package/dist/model/queryKeys.js +21 -0
  144. package/dist/model/queryKeys.js.map +1 -0
  145. package/dist/model/runtime.d.ts +39 -0
  146. package/dist/model/runtime.d.ts.map +1 -0
  147. package/dist/model/runtime.js +44 -0
  148. package/dist/model/runtime.js.map +1 -0
  149. package/dist/model/session.d.ts +50 -0
  150. package/dist/model/session.d.ts.map +1 -0
  151. package/dist/model/session.js +124 -0
  152. package/dist/model/session.js.map +1 -0
  153. package/package.json +68 -0
  154. package/src/api/authApi.ts +332 -0
  155. package/src/api/types.ts +291 -0
  156. package/src/api/urls.ts +99 -0
  157. package/src/flows/anonymousFlow.ts +57 -0
  158. package/src/flows/authenticatorChangeFlow.ts +160 -0
  159. package/src/flows/createFlowMachine.ts +126 -0
  160. package/src/flows/errors.ts +29 -0
  161. package/src/flows/magicLinkFlow.ts +68 -0
  162. package/src/flows/oauthFlow.ts +114 -0
  163. package/src/flows/otpFlow.ts +156 -0
  164. package/src/flows/passkeyFlow.ts +191 -0
  165. package/src/flows/passwordChangeFlow.ts +114 -0
  166. package/src/flows/passwordLoginFlow.ts +122 -0
  167. package/src/flows/passwordResetFlow.ts +123 -0
  168. package/src/flows/qrLoginFlow.ts +158 -0
  169. package/src/flows/ssoFlow.ts +84 -0
  170. package/src/flows/totpSetupFlow.ts +96 -0
  171. package/src/flows/useFlow.ts +16 -0
  172. package/src/flows/verificationFlow.ts +341 -0
  173. package/src/headless/AuthProvider.tsx +30 -0
  174. package/src/headless/Passkey.tsx +97 -0
  175. package/src/headless/PasswordChange.tsx +46 -0
  176. package/src/headless/PasswordLogin.tsx +49 -0
  177. package/src/headless/PasswordReset.tsx +51 -0
  178. package/src/headless/PasswordlessLogin.tsx +60 -0
  179. package/src/headless/QrLogin.tsx +52 -0
  180. package/src/headless/TotpSetup.tsx +40 -0
  181. package/src/headless/VerificationChallenge.tsx +54 -0
  182. package/src/headless/misc.tsx +151 -0
  183. package/src/i18n/keys.ts +94 -0
  184. package/src/index.ts +229 -0
  185. package/src/model/context.tsx +51 -0
  186. package/src/model/mutations.ts +152 -0
  187. package/src/model/queries.ts +130 -0
  188. package/src/model/queryKeys.ts +32 -0
  189. package/src/model/runtime.ts +93 -0
  190. package/src/model/session.ts +188 -0
  191. package/tsconfig.json +26 -0
@@ -0,0 +1,99 @@
1
+ /**
2
+ * URL builders for the **browser-redirect** auth endpoints (auth-sa.md §7/§8/
3
+ * §15/§18). These must never be called with `fetch` — they are full-page
4
+ * navigations. Callers do `window.location.assign(authUrls(...).xyz)`.
5
+ *
6
+ * Plus the two open-redirect defence helpers of auth-sa.md §19.2. Every new
7
+ * `?somewhere=` parameter that ends up in `location.href`/`navigate()` must
8
+ * pass through one of these rather than trust raw input.
9
+ */
10
+
11
+ function trimBase(baseUrl: string): string {
12
+ return baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
13
+ }
14
+
15
+ export interface AuthUrls {
16
+ /** Server-side OAuth redirect (auth-sa.md §7 option A). */
17
+ oauthAuthorize(provider: string, redirectUri: string): string;
18
+ /** Enterprise SSO login redirect (auth-sa.md §18.1 step 2). */
19
+ ssoLogin(orgSlug: string): string;
20
+ /** The URL embedded in a QR image — opened by the scanner's browser only. */
21
+ qrScan(key: string): string;
22
+ /** SP metadata URL surfaced to a customer's IT admin (auth-sa.md §18.4). */
23
+ ssoSamlMetadata(orgSlug: string): string;
24
+ }
25
+
26
+ /** Build the browser-redirect URLs against a client base URL (e.g. `/auth/api`). */
27
+ export function authUrls(baseUrl: string): AuthUrls {
28
+ const base = trimBase(baseUrl);
29
+ return {
30
+ oauthAuthorize: (provider, redirectUri) =>
31
+ `${base}/oauth/${provider}/authorize/?redirect_uri=${encodeURIComponent(
32
+ redirectUri
33
+ )}`,
34
+ ssoLogin: (orgSlug) => `${base}/sso/${orgSlug}/login/`,
35
+ qrScan: (key) => `${base}/qr/${key}/scan/`,
36
+ ssoSamlMetadata: (orgSlug) => `${base}/sso/${orgSlug}/saml/metadata/`,
37
+ };
38
+ }
39
+
40
+ /**
41
+ * `redirect_url` for magic-link / QR generation must be a **relative** path
42
+ * starting with a single `/` (auth-sa.md §8/§15 — open-redirect defence).
43
+ * Returns the path unchanged when valid, else `null`.
44
+ */
45
+ export function validRedirectUrl(raw: string): string | null {
46
+ if (raw.length === 0) return null;
47
+ if (!raw.startsWith("/")) return null;
48
+ if (raw.startsWith("//")) return null;
49
+ return raw;
50
+ }
51
+
52
+ /**
53
+ * auth-sa.md §19.2 `safeNextPath`: accept a relative path (single leading `/`,
54
+ * not `//`) or a same-origin absolute URL reduced to `pathname+search+hash`.
55
+ * Anything cross-origin / unrecognised returns `null`; callers fall back to a
56
+ * safe default (e.g. `/app`).
57
+ */
58
+ export function safeNextPath(
59
+ raw: string | null | undefined,
60
+ origin?: string
61
+ ): string | null {
62
+ if (raw == null || raw.length === 0) return null;
63
+ if (raw.startsWith("/") && !raw.startsWith("//")) return raw;
64
+ const selfOrigin =
65
+ origin ??
66
+ (typeof window !== "undefined" ? window.location.origin : undefined);
67
+ if (selfOrigin === undefined) return null;
68
+ try {
69
+ const url = new URL(raw, selfOrigin);
70
+ if (url.origin !== selfOrigin) return null;
71
+ return `${url.pathname}${url.search}${url.hash}`;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * auth-sa.md §19.2 `safeScanRedirect`: accept only same-origin URLs whose path
79
+ * starts with `/auth/api/qr/` (the QR scan-flow continuation). Used for
80
+ * `?redirect=` on `/sign-in`.
81
+ */
82
+ export function safeScanRedirect(
83
+ raw: string | null | undefined,
84
+ origin?: string
85
+ ): string | null {
86
+ if (raw == null || raw.length === 0) return null;
87
+ const selfOrigin =
88
+ origin ??
89
+ (typeof window !== "undefined" ? window.location.origin : undefined);
90
+ if (selfOrigin === undefined) return null;
91
+ try {
92
+ const url = new URL(raw, selfOrigin);
93
+ if (url.origin !== selfOrigin) return null;
94
+ if (!url.pathname.startsWith("/auth/api/qr/")) return null;
95
+ return url.toString();
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
@@ -0,0 +1,57 @@
1
+ import type { Analytics } from "@stapel/core";
2
+ import type { AuthApi } from "../api/authApi.js";
3
+ import type { AuthResponse } from "../api/types.js";
4
+ import { createFlowMachine } from "./createFlowMachine.js";
5
+ import type { FlowMachine } from "./createFlowMachine.js";
6
+ import { toFlowError } from "./errors.js";
7
+ import type { FlowError } from "./errors.js";
8
+
9
+ /**
10
+ * Anonymous session for onboarding-before-signup (auth-sa.md §6). A single
11
+ * call; passing the same `device_id` within 60 s dedups to the same anonymous
12
+ * user. A later email/phone verify upgrades or merges this session.
13
+ */
14
+ export type AnonymousState =
15
+ | { readonly step: "idle" }
16
+ | { readonly step: "creating" }
17
+ | { readonly step: "authenticated"; readonly result: AuthResponse }
18
+ | { readonly step: "error"; readonly error: FlowError };
19
+
20
+ export interface AnonymousFlow {
21
+ readonly machine: FlowMachine<AnonymousState>;
22
+ create(deviceId?: string): Promise<void>;
23
+ reset(): void;
24
+ }
25
+
26
+ export interface AnonymousFlowDeps {
27
+ readonly api: AuthApi;
28
+ readonly analytics?: Analytics | null;
29
+ readonly onAuthenticated?: (result: AuthResponse) => void;
30
+ }
31
+
32
+ export function createAnonymousFlow(deps: AnonymousFlowDeps): AnonymousFlow {
33
+ const machine = createFlowMachine<AnonymousState>({
34
+ id: "auth.anonymous",
35
+ initial: { step: "idle" },
36
+ analytics: deps.analytics ?? null,
37
+ });
38
+
39
+ async function create(deviceId?: string): Promise<void> {
40
+ await machine.run({ step: "creating" }, () => deps.api.anonymous(deviceId), {
41
+ resolve: (result): AnonymousState => {
42
+ deps.onAuthenticated?.(result);
43
+ return { step: "authenticated", result };
44
+ },
45
+ reject: (error): AnonymousState => ({
46
+ step: "error",
47
+ error: toFlowError(error),
48
+ }),
49
+ });
50
+ }
51
+
52
+ function reset(): void {
53
+ machine.to({ step: "idle" });
54
+ }
55
+
56
+ return { machine, create, reset };
57
+ }
@@ -0,0 +1,160 @@
1
+ import type { Analytics } from "@stapel/core";
2
+ import type { AuthApi } from "../api/authApi.js";
3
+ import type { AuthResponse, OtpChannel } from "../api/types.js";
4
+ import { createFlowMachine } from "./createFlowMachine.js";
5
+ import type { FlowMachine } from "./createFlowMachine.js";
6
+ import { toFlowError } from "./errors.js";
7
+ import type { FlowError } from "./errors.js";
8
+
9
+ /**
10
+ * Authenticator (email/phone) change — INSTANT strategy (auth-sa.md §9). A
11
+ * four-hop human-wait chain proving the *old* value, then setting the *new*:
12
+ * request-old → verify-old (mints a `change_token`) → request-new → verify-new
13
+ * (→ `MODIFIED` session). The DELAYED strategy (14-day wait, no old access) is
14
+ * plain CRUD — use `api.changeDelayedInitiate/Status/Cancel` or the
15
+ * `useDelayedChangeStatus` model hook directly.
16
+ */
17
+ export type AuthenticatorChangeState =
18
+ | { readonly step: "idle" }
19
+ | { readonly step: "requestingOld"; readonly channel: OtpChannel }
20
+ | { readonly step: "oldCodeSent"; readonly channel: OtpChannel; readonly target: string }
21
+ | { readonly step: "verifyingOld"; readonly channel: OtpChannel }
22
+ | {
23
+ readonly step: "oldVerified";
24
+ readonly channel: OtpChannel;
25
+ readonly changeToken: string;
26
+ }
27
+ | {
28
+ readonly step: "requestingNew";
29
+ readonly channel: OtpChannel;
30
+ readonly changeToken: string;
31
+ readonly newValue: string;
32
+ }
33
+ | {
34
+ readonly step: "newCodeSent";
35
+ readonly channel: OtpChannel;
36
+ readonly changeToken: string;
37
+ readonly newValue: string;
38
+ readonly target: string;
39
+ }
40
+ | {
41
+ readonly step: "verifyingNew";
42
+ readonly channel: OtpChannel;
43
+ readonly changeToken: string;
44
+ readonly newValue: string;
45
+ }
46
+ | { readonly step: "changed"; readonly result: AuthResponse }
47
+ | { readonly step: "error"; readonly error: FlowError };
48
+
49
+ export interface AuthenticatorChangeFlow {
50
+ readonly machine: FlowMachine<AuthenticatorChangeState>;
51
+ startInstant(channel: OtpChannel): Promise<void>;
52
+ submitOldCode(code: string): Promise<void>;
53
+ requestNew(newValue: string): Promise<void>;
54
+ submitNewCode(code: string): Promise<void>;
55
+ reset(): void;
56
+ }
57
+
58
+ export interface AuthenticatorChangeFlowDeps {
59
+ readonly api: AuthApi;
60
+ readonly analytics?: Analytics | null;
61
+ readonly onAuthenticated?: (result: AuthResponse) => void;
62
+ }
63
+
64
+ export function createAuthenticatorChangeFlow(
65
+ deps: AuthenticatorChangeFlowDeps
66
+ ): AuthenticatorChangeFlow {
67
+ const machine = createFlowMachine<AuthenticatorChangeState>({
68
+ id: "auth.authenticator_change",
69
+ initial: { step: "idle" },
70
+ analytics: deps.analytics ?? null,
71
+ });
72
+
73
+ async function startInstant(channel: OtpChannel): Promise<void> {
74
+ await machine.run(
75
+ { step: "requestingOld", channel },
76
+ () => deps.api.changeInstantRequestOld(channel),
77
+ {
78
+ resolve: (r): AuthenticatorChangeState => ({
79
+ step: "oldCodeSent",
80
+ channel,
81
+ target: r.target,
82
+ }),
83
+ reject: (error): AuthenticatorChangeState => ({
84
+ step: "error",
85
+ error: toFlowError(error),
86
+ }),
87
+ }
88
+ );
89
+ }
90
+
91
+ async function submitOldCode(code: string): Promise<void> {
92
+ const s = machine.getState();
93
+ if (s.step !== "oldCodeSent") return;
94
+ const { channel } = s;
95
+ await machine.run(
96
+ { step: "verifyingOld", channel },
97
+ () => deps.api.changeInstantVerifyOld(channel, code),
98
+ {
99
+ resolve: (r): AuthenticatorChangeState => ({
100
+ step: "oldVerified",
101
+ channel,
102
+ changeToken: r.change_token,
103
+ }),
104
+ reject: (error): AuthenticatorChangeState => ({
105
+ step: "error",
106
+ error: toFlowError(error),
107
+ }),
108
+ }
109
+ );
110
+ }
111
+
112
+ async function requestNew(newValue: string): Promise<void> {
113
+ const s = machine.getState();
114
+ if (s.step !== "oldVerified") return;
115
+ const { channel, changeToken } = s;
116
+ await machine.run(
117
+ { step: "requestingNew", channel, changeToken, newValue },
118
+ () => deps.api.changeInstantRequestNew(channel, newValue, changeToken),
119
+ {
120
+ resolve: (r): AuthenticatorChangeState => ({
121
+ step: "newCodeSent",
122
+ channel,
123
+ changeToken,
124
+ newValue,
125
+ target: r.target,
126
+ }),
127
+ reject: (error): AuthenticatorChangeState => ({
128
+ step: "error",
129
+ error: toFlowError(error),
130
+ }),
131
+ }
132
+ );
133
+ }
134
+
135
+ async function submitNewCode(code: string): Promise<void> {
136
+ const s = machine.getState();
137
+ if (s.step !== "newCodeSent") return;
138
+ const { channel, changeToken, newValue } = s;
139
+ await machine.run(
140
+ { step: "verifyingNew", channel, changeToken, newValue },
141
+ () => deps.api.changeInstantVerifyNew(channel, newValue, code, changeToken),
142
+ {
143
+ resolve: (result): AuthenticatorChangeState => {
144
+ deps.onAuthenticated?.(result);
145
+ return { step: "changed", result };
146
+ },
147
+ reject: (error): AuthenticatorChangeState => ({
148
+ step: "error",
149
+ error: toFlowError(error),
150
+ }),
151
+ }
152
+ );
153
+ }
154
+
155
+ function reset(): void {
156
+ machine.to({ step: "idle" });
157
+ }
158
+
159
+ return { machine, startInstant, submitOldCode, requestNew, submitNewCode, reset };
160
+ }
@@ -0,0 +1,126 @@
1
+ import { trackFlowStep } from "@stapel/core";
2
+ import type { Analytics } from "@stapel/core";
3
+
4
+ /**
5
+ * The shared flow-machine primitive — the FIRST INSTANCE of the pattern every
6
+ * later `@stapel/<module>-react` pair copies (frontend-standard §2, "flows/").
7
+ *
8
+ * A flow is a tiny state container whose state is a discriminated union keyed
9
+ * by a `step` string. Three things earn this primitive its keep across every
10
+ * auth journey:
11
+ *
12
+ * 1. **Typed transitions** — `to(next)` replaces state and notifies React via
13
+ * `useSyncExternalStore` (see `useFlow`).
14
+ * 2. **Human-wait vs async steps** — a step with no pending work is just a
15
+ * resting state the machine waits in for user input (`to`). Async steps go
16
+ * through `run`, which parks the machine in a `pending` step, awaits the
17
+ * task, then transitions to a success/failure step. `run` never throws:
18
+ * rejections are folded into a state, so hosts render errors instead of
19
+ * catching them. `run` is also **staleness-guarded**: if a newer `to`
20
+ * happens while the task is in flight (double-submit, cancel, navigate,
21
+ * challenge expiry), the late result is dropped — it never clobbers the
22
+ * newer state nor runs its resolve/reject side effects.
23
+ * 3. **Auto-instrumentation** — every transition emits
24
+ * `flow.<id>.<step>` (`started`), and every `run` emits `completed` /
25
+ * `failed` for its pending step (analytics-standard §1.2). Funnels exist
26
+ * without hand-written tracking.
27
+ */
28
+ export interface FlowStateBase {
29
+ readonly step: string;
30
+ }
31
+
32
+ export interface FlowMachine<S extends FlowStateBase> {
33
+ /** Analytics flow id — the `<id>` in `flow.<id>.<step>`. */
34
+ readonly id: string;
35
+ getState(): S;
36
+ subscribe(listener: () => void): () => void;
37
+ /** Transition to a resting/human-wait state; emits `<step>` started. */
38
+ to(next: S): void;
39
+ /**
40
+ * Run an async step. Parks in `pending` immediately, awaits `task`, then
41
+ * transitions via `resolve`/`reject`. Emits `completed`/`failed` for the
42
+ * pending step. Resolves once the terminal transition is applied — it does
43
+ * not reject.
44
+ */
45
+ run<T>(
46
+ pending: S,
47
+ task: () => Promise<T>,
48
+ handlers: {
49
+ readonly resolve: (result: T) => S;
50
+ readonly reject: (error: unknown) => S;
51
+ }
52
+ ): Promise<void>;
53
+ }
54
+
55
+ export interface FlowMachineOptions<S extends FlowStateBase> {
56
+ /** Analytics flow id (e.g. `"auth.otp"`). */
57
+ readonly id: string;
58
+ readonly initial: S;
59
+ /** Facade for auto-instrumentation. Omit to disable tracking. */
60
+ readonly analytics?: Analytics | null;
61
+ }
62
+
63
+ export function createFlowMachine<S extends FlowStateBase>(
64
+ options: FlowMachineOptions<S>
65
+ ): FlowMachine<S> {
66
+ const { id, analytics } = options;
67
+ let state = options.initial;
68
+ const listeners = new Set<() => void>();
69
+
70
+ function emit(step: string, phase: "started" | "completed" | "failed"): void {
71
+ if (analytics) trackFlowStep(analytics, id, step, phase);
72
+ }
73
+
74
+ function notify(): void {
75
+ for (const listener of listeners) listener();
76
+ }
77
+
78
+ // Monotonic transition counter — the staleness epoch. Every `to` bumps it; a
79
+ // `run` captures it after parking in `pending` and only applies its terminal
80
+ // transition if no newer `to` happened while the task was in flight.
81
+ let generation = 0;
82
+
83
+ function to(next: S): void {
84
+ state = next;
85
+ generation += 1;
86
+ emit(next.step, "started");
87
+ notify();
88
+ }
89
+
90
+ async function run<T>(
91
+ pending: S,
92
+ task: () => Promise<T>,
93
+ handlers: {
94
+ readonly resolve: (result: T) => S;
95
+ readonly reject: (error: unknown) => S;
96
+ }
97
+ ): Promise<void> {
98
+ to(pending);
99
+ // Epoch of THIS run's pending transition. If it advances before the task
100
+ // settles (double-submit, cancel, navigate, expiry), the late result is
101
+ // stale and must NOT clobber the newer state — nor run its resolve/reject
102
+ // side effects. Analytics still fires so funnels stay honest.
103
+ const epoch = generation;
104
+ try {
105
+ const result = await task();
106
+ emit(pending.step, "completed");
107
+ if (generation === epoch) to(handlers.resolve(result));
108
+ } catch (error) {
109
+ emit(pending.step, "failed");
110
+ if (generation === epoch) to(handlers.reject(error));
111
+ }
112
+ }
113
+
114
+ return {
115
+ id,
116
+ getState: () => state,
117
+ subscribe: (listener) => {
118
+ listeners.add(listener);
119
+ return () => {
120
+ listeners.delete(listener);
121
+ };
122
+ },
123
+ to,
124
+ run,
125
+ };
126
+ }
@@ -0,0 +1,29 @@
1
+ import { StapelApiError } from "@stapel/core";
2
+
3
+ /**
4
+ * Normalized error shape carried by flow error states. `code` is the backend
5
+ * `localizable_error` i18n key (auth-sa.md "Error reference"); `params` feed
6
+ * `{param}` interpolation (e.g. `retry_after_minutes`, `attempts_remaining`).
7
+ */
8
+ export interface FlowError {
9
+ readonly code: string;
10
+ readonly params: Readonly<Record<string, unknown>>;
11
+ readonly status: number | undefined;
12
+ }
13
+
14
+ /** Fold any thrown value into a {@link FlowError} for a flow error state. */
15
+ export function toFlowError(error: unknown): FlowError {
16
+ if (error instanceof StapelApiError) {
17
+ return { code: error.code, params: error.params, status: error.status };
18
+ }
19
+ return {
20
+ code: "auth.error.unknown",
21
+ params: {},
22
+ status: undefined,
23
+ };
24
+ }
25
+
26
+ /** Convenience predicate: did this error carry a specific backend code? */
27
+ export function isErrorCode(error: FlowError, code: string): boolean {
28
+ return error.code === code;
29
+ }
@@ -0,0 +1,68 @@
1
+ import type { Analytics } from "@stapel/core";
2
+ import type { AuthApi } from "../api/authApi.js";
3
+ import { validRedirectUrl } from "../api/urls.js";
4
+ import { createFlowMachine } from "./createFlowMachine.js";
5
+ import type { FlowMachine } from "./createFlowMachine.js";
6
+ import { toFlowError } from "./errors.js";
7
+ import type { FlowError } from "./errors.js";
8
+
9
+ /**
10
+ * Magic-link request (auth-sa.md §15). Only the *request* is a frontend flow:
11
+ * the email link points directly at the backend (`/auth/api/magic/verify/`),
12
+ * so there is no `/magic-login` page — the browser opens the link and the
13
+ * backend redirects (TOTP / conflict / success handled by the `/login` route
14
+ * consumers, not this flow). Always resolves `sent` on 200 regardless of
15
+ * whether the email exists (enumeration protection); only rate-limit /
16
+ * validation errors surface.
17
+ */
18
+ export type MagicLinkState =
19
+ | { readonly step: "idle" }
20
+ | { readonly step: "requesting"; readonly email: string }
21
+ | { readonly step: "sent"; readonly email: string }
22
+ | { readonly step: "invalidRedirect"; readonly email: string }
23
+ | { readonly step: "error"; readonly email: string; readonly error: FlowError };
24
+
25
+ export interface MagicLinkFlow {
26
+ readonly machine: FlowMachine<MagicLinkState>;
27
+ /** `redirectUrl` must be a relative path (`/...`); rejected client-side otherwise. */
28
+ request(email: string, redirectUrl?: string): Promise<void>;
29
+ reset(): void;
30
+ }
31
+
32
+ export interface MagicLinkFlowDeps {
33
+ readonly api: AuthApi;
34
+ readonly analytics?: Analytics | null;
35
+ }
36
+
37
+ export function createMagicLinkFlow(deps: MagicLinkFlowDeps): MagicLinkFlow {
38
+ const machine = createFlowMachine<MagicLinkState>({
39
+ id: "auth.magic_link",
40
+ initial: { step: "idle" },
41
+ analytics: deps.analytics ?? null,
42
+ });
43
+
44
+ async function request(email: string, redirectUrl?: string): Promise<void> {
45
+ if (redirectUrl !== undefined && validRedirectUrl(redirectUrl) === null) {
46
+ machine.to({ step: "invalidRedirect", email });
47
+ return;
48
+ }
49
+ await machine.run(
50
+ { step: "requesting", email },
51
+ () => deps.api.magicRequest(email, redirectUrl),
52
+ {
53
+ resolve: (): MagicLinkState => ({ step: "sent", email }),
54
+ reject: (error): MagicLinkState => ({
55
+ step: "error",
56
+ email,
57
+ error: toFlowError(error),
58
+ }),
59
+ }
60
+ );
61
+ }
62
+
63
+ function reset(): void {
64
+ machine.to({ step: "idle" });
65
+ }
66
+
67
+ return { machine, request, reset };
68
+ }
@@ -0,0 +1,114 @@
1
+ import type { Analytics } from "@stapel/core";
2
+ import type { AuthApi } from "../api/authApi.js";
3
+ import type { AuthResponse } from "../api/types.js";
4
+ import { isTotpChallenge } from "../api/types.js";
5
+ import { createFlowMachine } from "./createFlowMachine.js";
6
+ import type { FlowMachine } from "./createFlowMachine.js";
7
+ import { toFlowError } from "./errors.js";
8
+ import type { FlowError } from "./errors.js";
9
+
10
+ /**
11
+ * OAuth client-side token exchange (auth-sa.md §7 option B). For the
12
+ * server-side redirect (option A, recommended for web) use
13
+ * `authUrls(base).oauthAuthorize(provider, redirectUri)` — no flow needed, the
14
+ * backend sets cookies on return.
15
+ *
16
+ * The exchange can answer with the same TOTP `oneOf` union as password login
17
+ * (only when `OAUTH_STEP_UP` is enabled server-side), so this flow carries the
18
+ * identical `totpRequired` branch — completed via the same
19
+ * `/totp/challenge/verify/` endpoint.
20
+ */
21
+ export type OAuthState =
22
+ | { readonly step: "idle" }
23
+ | { readonly step: "exchanging"; readonly provider: string }
24
+ | {
25
+ readonly step: "totpRequired";
26
+ readonly challengeToken: string;
27
+ readonly expiresIn: number;
28
+ }
29
+ | { readonly step: "verifyingTotp"; readonly challengeToken: string; readonly expiresIn: number }
30
+ | { readonly step: "authenticated"; readonly result: AuthResponse }
31
+ | { readonly step: "error"; readonly provider: string; readonly error: FlowError }
32
+ | {
33
+ readonly step: "totpError";
34
+ readonly challengeToken: string;
35
+ readonly expiresIn: number;
36
+ readonly error: FlowError;
37
+ };
38
+
39
+ export interface OAuthFlow {
40
+ readonly machine: FlowMachine<OAuthState>;
41
+ exchange(provider: string, accessToken: string): Promise<void>;
42
+ submitTotp(proof: { code?: string; backup_code?: string }): Promise<void>;
43
+ reset(): void;
44
+ }
45
+
46
+ export interface OAuthFlowDeps {
47
+ readonly api: AuthApi;
48
+ readonly analytics?: Analytics | null;
49
+ readonly onAuthenticated?: (result: AuthResponse) => void;
50
+ }
51
+
52
+ export function createOAuthFlow(deps: OAuthFlowDeps): OAuthFlow {
53
+ const machine = createFlowMachine<OAuthState>({
54
+ id: "auth.oauth",
55
+ initial: { step: "idle" },
56
+ analytics: deps.analytics ?? null,
57
+ });
58
+
59
+ async function exchange(provider: string, accessToken: string): Promise<void> {
60
+ await machine.run(
61
+ { step: "exchanging", provider },
62
+ () => deps.api.oauthLogin(provider, accessToken),
63
+ {
64
+ resolve: (r): OAuthState => {
65
+ if (isTotpChallenge(r)) {
66
+ return {
67
+ step: "totpRequired",
68
+ challengeToken: r.challenge_token,
69
+ expiresIn: r.expires_in,
70
+ };
71
+ }
72
+ deps.onAuthenticated?.(r);
73
+ return { step: "authenticated", result: r };
74
+ },
75
+ reject: (error): OAuthState => ({
76
+ step: "error",
77
+ provider,
78
+ error: toFlowError(error),
79
+ }),
80
+ }
81
+ );
82
+ }
83
+
84
+ async function submitTotp(proof: {
85
+ code?: string;
86
+ backup_code?: string;
87
+ }): Promise<void> {
88
+ const s = machine.getState();
89
+ if (s.step !== "totpRequired" && s.step !== "totpError") return;
90
+ const { challengeToken, expiresIn } = s;
91
+ await machine.run(
92
+ { step: "verifyingTotp", challengeToken, expiresIn },
93
+ () => deps.api.totpChallengeVerify(challengeToken, proof),
94
+ {
95
+ resolve: (result): OAuthState => {
96
+ deps.onAuthenticated?.(result);
97
+ return { step: "authenticated", result };
98
+ },
99
+ reject: (error): OAuthState => ({
100
+ step: "totpError",
101
+ challengeToken,
102
+ expiresIn,
103
+ error: toFlowError(error),
104
+ }),
105
+ }
106
+ );
107
+ }
108
+
109
+ function reset(): void {
110
+ machine.to({ step: "idle" });
111
+ }
112
+
113
+ return { machine, exchange, submitTotp, reset };
114
+ }