@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,156 @@
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
+ * Email / Phone OTP passwordless login (auth-sa.md §1–2). One machine serves
11
+ * both channels — the only difference is the endpoint, carried in `channel`.
12
+ *
13
+ * Steps:
14
+ * - `idle` → nothing requested yet
15
+ * - `requesting` → POST /<channel>/request/ in flight
16
+ * - `codeSent` → human-wait: enter the code (or resend)
17
+ * - `verifying` → POST /<channel>/verify/ in flight
18
+ * - `authenticated`→ terminal success (AuthResponse)
19
+ * - `requestError` → request failed; retry
20
+ * - `codeError` → wrong/expired code; human-wait, retry or resend
21
+ * - `locked` → 423 lockout with `retry_after_minutes`
22
+ */
23
+ export type OtpState =
24
+ | { readonly step: "idle" }
25
+ | { readonly step: "requesting"; readonly channel: OtpChannel; readonly value: string }
26
+ | {
27
+ readonly step: "codeSent";
28
+ readonly channel: OtpChannel;
29
+ readonly value: string;
30
+ readonly target: string;
31
+ }
32
+ | { readonly step: "verifying"; readonly channel: OtpChannel; readonly value: string; readonly target: string }
33
+ | { readonly step: "authenticated"; readonly result: AuthResponse }
34
+ | {
35
+ readonly step: "requestError";
36
+ readonly channel: OtpChannel;
37
+ readonly value: string;
38
+ readonly error: FlowError;
39
+ }
40
+ | {
41
+ readonly step: "codeError";
42
+ readonly channel: OtpChannel;
43
+ readonly value: string;
44
+ readonly target: string;
45
+ readonly error: FlowError;
46
+ }
47
+ | {
48
+ readonly step: "locked";
49
+ readonly channel: OtpChannel;
50
+ readonly value: string;
51
+ readonly error: FlowError;
52
+ };
53
+
54
+ export interface OtpFlow {
55
+ readonly machine: FlowMachine<OtpState>;
56
+ /** Request a code for the identifier. `captchaToken` when the backend needs it. */
57
+ requestCode(channel: OtpChannel, value: string, captchaToken?: string): Promise<void>;
58
+ /** Resend the code for the current identifier (respect the 30 s rate limit). */
59
+ resend(captchaToken?: string): Promise<void>;
60
+ /** Verify the entered code. */
61
+ submitCode(code: string): Promise<void>;
62
+ /** Return to `idle`. */
63
+ reset(): void;
64
+ }
65
+
66
+ export interface OtpFlowDeps {
67
+ readonly api: AuthApi;
68
+ readonly analytics?: Analytics | null;
69
+ /** Called with the session-bearing response on success (token persistence). */
70
+ readonly onAuthenticated?: (result: AuthResponse) => void;
71
+ }
72
+
73
+ const LOCKED_STATUS = 423;
74
+
75
+ export function createOtpFlow(deps: OtpFlowDeps): OtpFlow {
76
+ const machine = createFlowMachine<OtpState>({
77
+ id: "auth.otp",
78
+ initial: { step: "idle" },
79
+ analytics: deps.analytics ?? null,
80
+ });
81
+
82
+ async function requestCode(
83
+ channel: OtpChannel,
84
+ value: string,
85
+ captchaToken?: string
86
+ ): Promise<void> {
87
+ await machine.run(
88
+ { step: "requesting", channel, value },
89
+ () => deps.api.otpRequest(channel, value, captchaToken),
90
+ {
91
+ resolve: (r): OtpState => ({
92
+ step: "codeSent",
93
+ channel,
94
+ value,
95
+ target: r.target,
96
+ }),
97
+ reject: (error): OtpState => {
98
+ const flowError = toFlowError(error);
99
+ if (flowError.status === LOCKED_STATUS) {
100
+ return { step: "locked", channel, value, error: flowError };
101
+ }
102
+ return { step: "requestError", channel, value, error: flowError };
103
+ },
104
+ }
105
+ );
106
+ }
107
+
108
+ function currentIdentifier(): { channel: OtpChannel; value: string } | null {
109
+ const s = machine.getState();
110
+ if (
111
+ s.step === "codeSent" ||
112
+ s.step === "verifying" ||
113
+ s.step === "codeError" ||
114
+ s.step === "requestError" ||
115
+ s.step === "requesting"
116
+ ) {
117
+ return { channel: s.channel, value: s.value };
118
+ }
119
+ return null;
120
+ }
121
+
122
+ async function resend(captchaToken?: string): Promise<void> {
123
+ const id = currentIdentifier();
124
+ if (id === null) return;
125
+ await requestCode(id.channel, id.value, captchaToken);
126
+ }
127
+
128
+ async function submitCode(code: string): Promise<void> {
129
+ const s = machine.getState();
130
+ if (s.step !== "codeSent" && s.step !== "codeError") return;
131
+ const { channel, value, target } = s;
132
+ await machine.run(
133
+ { step: "verifying", channel, value, target },
134
+ () => deps.api.otpVerify(channel, value, code),
135
+ {
136
+ resolve: (result): OtpState => {
137
+ deps.onAuthenticated?.(result);
138
+ return { step: "authenticated", result };
139
+ },
140
+ reject: (error): OtpState => {
141
+ const flowError = toFlowError(error);
142
+ if (flowError.status === LOCKED_STATUS) {
143
+ return { step: "locked", channel, value, error: flowError };
144
+ }
145
+ return { step: "codeError", channel, value, target, error: flowError };
146
+ },
147
+ }
148
+ );
149
+ }
150
+
151
+ function reset(): void {
152
+ machine.to({ step: "idle" });
153
+ }
154
+
155
+ return { machine, requestCode, resend, submitCode, reset };
156
+ }
@@ -0,0 +1,191 @@
1
+ import type { Analytics } from "@stapel/core";
2
+ import type { AuthApi } from "../api/authApi.js";
3
+ import type { AuthResponse, Passkey } 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
+ * Passkeys / WebAuthn (auth-sa.md §17). FLOW-COMPLETE, WEBAUTHN BINDING IS A
11
+ * THIN TODO (see MODULE.md): both machines model the full begin→ceremony→
12
+ * complete journey and surface the server `options`. The single browser step —
13
+ * `navigator.credentials.create()/get()` — is either injected via
14
+ * `webauthn*` deps (auto-driven) or performed by the host, which then calls
15
+ * `submitCredential`. No heuristic "no credentials" probing (auth-sa.md §19.6).
16
+ */
17
+
18
+ // ── Registration (security settings, requires auth) ─────────────────────────
19
+
20
+ export type PasskeyRegisterState =
21
+ | { readonly step: "idle" }
22
+ | { readonly step: "beginning" }
23
+ | { readonly step: "awaitingCredential"; readonly options: Record<string, unknown> }
24
+ | { readonly step: "completing" }
25
+ | { readonly step: "registered"; readonly passkey: Passkey }
26
+ | { readonly step: "error"; readonly error: FlowError };
27
+
28
+ export interface PasskeyRegistrationFlow {
29
+ readonly machine: FlowMachine<PasskeyRegisterState>;
30
+ begin(deviceName?: string): Promise<void>;
31
+ submitCredential(credential: unknown): Promise<void>;
32
+ reset(): void;
33
+ }
34
+
35
+ export interface PasskeyRegistrationFlowDeps {
36
+ readonly api: AuthApi;
37
+ readonly analytics?: Analytics | null;
38
+ /** THIN WebAuthn binding: `navigator.credentials.create({ publicKey })`. */
39
+ readonly webauthnCreate?: (
40
+ options: Record<string, unknown>
41
+ ) => Promise<unknown>;
42
+ }
43
+
44
+ export function createPasskeyRegistrationFlow(
45
+ deps: PasskeyRegistrationFlowDeps
46
+ ): PasskeyRegistrationFlow {
47
+ const machine = createFlowMachine<PasskeyRegisterState>({
48
+ id: "auth.passkey_register",
49
+ initial: { step: "idle" },
50
+ analytics: deps.analytics ?? null,
51
+ });
52
+
53
+ let pendingDeviceName: string | undefined;
54
+
55
+ async function submitCredential(credential: unknown): Promise<void> {
56
+ const s = machine.getState();
57
+ if (s.step !== "awaitingCredential") return;
58
+ await machine.run(
59
+ { step: "completing" },
60
+ () => deps.api.passkeyRegisterComplete(credential, pendingDeviceName),
61
+ {
62
+ resolve: (passkey): PasskeyRegisterState => ({ step: "registered", passkey }),
63
+ reject: (error): PasskeyRegisterState => ({
64
+ step: "error",
65
+ error: toFlowError(error),
66
+ }),
67
+ }
68
+ );
69
+ }
70
+
71
+ async function begin(deviceName?: string): Promise<void> {
72
+ pendingDeviceName = deviceName;
73
+ await machine.run({ step: "beginning" }, () => deps.api.passkeyRegisterBegin(), {
74
+ resolve: (r): PasskeyRegisterState => ({
75
+ step: "awaitingCredential",
76
+ options: r.options,
77
+ }),
78
+ reject: (error): PasskeyRegisterState => ({
79
+ step: "error",
80
+ error: toFlowError(error),
81
+ }),
82
+ });
83
+ const after = machine.getState();
84
+ if (after.step === "awaitingCredential" && deps.webauthnCreate) {
85
+ try {
86
+ const credential = await deps.webauthnCreate(after.options);
87
+ await submitCredential(credential);
88
+ } catch (error) {
89
+ machine.to({ step: "error", error: toFlowError(error) });
90
+ }
91
+ }
92
+ }
93
+
94
+ function reset(): void {
95
+ machine.to({ step: "idle" });
96
+ }
97
+
98
+ return { machine, begin, submitCredential, reset };
99
+ }
100
+
101
+ // ── Authentication (sign-in page, no auth required) ─────────────────────────
102
+
103
+ export type PasskeyLoginState =
104
+ | { readonly step: "idle" }
105
+ | { readonly step: "beginning" }
106
+ | {
107
+ readonly step: "awaitingAssertion";
108
+ readonly sessionKey: string;
109
+ readonly options: Record<string, unknown>;
110
+ }
111
+ | { readonly step: "completing"; readonly sessionKey: string }
112
+ | { readonly step: "authenticated"; readonly result: AuthResponse }
113
+ | { readonly step: "error"; readonly error: FlowError };
114
+
115
+ export interface PasskeyLoginFlow {
116
+ readonly machine: FlowMachine<PasskeyLoginState>;
117
+ begin(email?: string): Promise<void>;
118
+ submitAssertion(credential: unknown): Promise<void>;
119
+ reset(): void;
120
+ }
121
+
122
+ export interface PasskeyLoginFlowDeps {
123
+ readonly api: AuthApi;
124
+ readonly analytics?: Analytics | null;
125
+ readonly onAuthenticated?: (result: AuthResponse) => void;
126
+ /** THIN WebAuthn binding: `navigator.credentials.get({ publicKey })`. */
127
+ readonly webauthnGet?: (options: Record<string, unknown>) => Promise<unknown>;
128
+ }
129
+
130
+ export function createPasskeyLoginFlow(
131
+ deps: PasskeyLoginFlowDeps
132
+ ): PasskeyLoginFlow {
133
+ const machine = createFlowMachine<PasskeyLoginState>({
134
+ id: "auth.passkey_login",
135
+ initial: { step: "idle" },
136
+ analytics: deps.analytics ?? null,
137
+ });
138
+
139
+ async function submitAssertion(credential: unknown): Promise<void> {
140
+ const s = machine.getState();
141
+ if (s.step !== "awaitingAssertion") return;
142
+ const { sessionKey } = s;
143
+ await machine.run(
144
+ { step: "completing", sessionKey },
145
+ () => deps.api.passkeyAuthenticateComplete(sessionKey, credential),
146
+ {
147
+ resolve: (result): PasskeyLoginState => {
148
+ deps.onAuthenticated?.(result);
149
+ return { step: "authenticated", result };
150
+ },
151
+ reject: (error): PasskeyLoginState => ({
152
+ step: "error",
153
+ error: toFlowError(error),
154
+ }),
155
+ }
156
+ );
157
+ }
158
+
159
+ async function begin(email?: string): Promise<void> {
160
+ await machine.run(
161
+ { step: "beginning" },
162
+ () => deps.api.passkeyAuthenticateBegin(email),
163
+ {
164
+ resolve: (r): PasskeyLoginState => ({
165
+ step: "awaitingAssertion",
166
+ sessionKey: r.session_key,
167
+ options: r.options,
168
+ }),
169
+ reject: (error): PasskeyLoginState => ({
170
+ step: "error",
171
+ error: toFlowError(error),
172
+ }),
173
+ }
174
+ );
175
+ const after = machine.getState();
176
+ if (after.step === "awaitingAssertion" && deps.webauthnGet) {
177
+ try {
178
+ const credential = await deps.webauthnGet(after.options);
179
+ await submitAssertion(credential);
180
+ } catch (error) {
181
+ machine.to({ step: "error", error: toFlowError(error) });
182
+ }
183
+ }
184
+ }
185
+
186
+ function reset(): void {
187
+ machine.to({ step: "idle" });
188
+ }
189
+
190
+ return { machine, begin, submitAssertion, reset };
191
+ }
@@ -0,0 +1,114 @@
1
+ import type { Analytics } from "@stapel/core";
2
+ import type { AuthApi } from "../api/authApi.js";
3
+ import type { 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
+ * Authenticated password change (auth-sa.md §4). Two host-selected paths that
11
+ * converge on `changed`:
12
+ * - **old password** — `changeWithPassword(old, new)`.
13
+ * - **email / SMS code** — `requestOtp(method)` → `submitOtp(code, new)`.
14
+ *
15
+ * Which tabs to show is a host decision driven by `GET /password/methods/`
16
+ * (see `usePasswordMethods`); the machine serves whichever path is invoked.
17
+ */
18
+ export type PasswordChangeState =
19
+ | { readonly step: "idle" }
20
+ | { readonly step: "changing" }
21
+ | { readonly step: "requestingOtp"; readonly method: OtpChannel }
22
+ | { readonly step: "otpSent"; readonly method: OtpChannel; readonly target: string }
23
+ | { readonly step: "verifyingOtp"; readonly method: OtpChannel; readonly target: string }
24
+ | { readonly step: "changed" }
25
+ | { readonly step: "error"; readonly error: FlowError }
26
+ | {
27
+ readonly step: "otpError";
28
+ readonly method: OtpChannel;
29
+ readonly target: string;
30
+ readonly error: FlowError;
31
+ };
32
+
33
+ export interface PasswordChangeFlow {
34
+ readonly machine: FlowMachine<PasswordChangeState>;
35
+ changeWithPassword(oldPassword: string, newPassword: string): Promise<void>;
36
+ requestOtp(method: OtpChannel): Promise<void>;
37
+ submitOtp(code: string, newPassword: string): Promise<void>;
38
+ reset(): void;
39
+ }
40
+
41
+ export interface PasswordChangeFlowDeps {
42
+ readonly api: AuthApi;
43
+ readonly analytics?: Analytics | null;
44
+ }
45
+
46
+ export function createPasswordChangeFlow(
47
+ deps: PasswordChangeFlowDeps
48
+ ): PasswordChangeFlow {
49
+ const machine = createFlowMachine<PasswordChangeState>({
50
+ id: "auth.password_change",
51
+ initial: { step: "idle" },
52
+ analytics: deps.analytics ?? null,
53
+ });
54
+
55
+ async function changeWithPassword(
56
+ oldPassword: string,
57
+ newPassword: string
58
+ ): Promise<void> {
59
+ await machine.run(
60
+ { step: "changing" },
61
+ () => deps.api.passwordChange(oldPassword, newPassword),
62
+ {
63
+ resolve: (): PasswordChangeState => ({ step: "changed" }),
64
+ reject: (error): PasswordChangeState => ({
65
+ step: "error",
66
+ error: toFlowError(error),
67
+ }),
68
+ }
69
+ );
70
+ }
71
+
72
+ async function requestOtp(method: OtpChannel): Promise<void> {
73
+ await machine.run(
74
+ { step: "requestingOtp", method },
75
+ () => deps.api.passwordChangeOtpRequest(method),
76
+ {
77
+ resolve: (r): PasswordChangeState => ({
78
+ step: "otpSent",
79
+ method,
80
+ target: r.target,
81
+ }),
82
+ reject: (error): PasswordChangeState => ({
83
+ step: "error",
84
+ error: toFlowError(error),
85
+ }),
86
+ }
87
+ );
88
+ }
89
+
90
+ async function submitOtp(code: string, newPassword: string): Promise<void> {
91
+ const s = machine.getState();
92
+ if (s.step !== "otpSent" && s.step !== "otpError") return;
93
+ const { method, target } = s;
94
+ await machine.run(
95
+ { step: "verifyingOtp", method, target },
96
+ () => deps.api.passwordChangeOtpVerify(method, code, newPassword),
97
+ {
98
+ resolve: (): PasswordChangeState => ({ step: "changed" }),
99
+ reject: (error): PasswordChangeState => ({
100
+ step: "otpError",
101
+ method,
102
+ target,
103
+ error: toFlowError(error),
104
+ }),
105
+ }
106
+ );
107
+ }
108
+
109
+ function reset(): void {
110
+ machine.to({ step: "idle" });
111
+ }
112
+
113
+ return { machine, changeWithPassword, requestOtp, submitOtp, reset };
114
+ }
@@ -0,0 +1,122 @@
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
+ * Password login with the TOTP step-up branch (auth-sa.md §3 + §11). A login
12
+ * response is a `oneOf` union — when the account has TOTP enabled the server
13
+ * answers `TOTP_REQUIRED` with a `challenge_token` instead of a session, and
14
+ * the flow parks in `totpRequired` until the user enters their 6-digit code
15
+ * (or a backup code).
16
+ *
17
+ * A 423 during TOTP verify **invalidates the challenge** (auth-sa.md §11): the
18
+ * machine goes to `totpLocked` and the user must log in again for a fresh
19
+ * token — modelled as a terminal step (call `reset()` to return to `idle`).
20
+ */
21
+ export type PasswordLoginState =
22
+ | { readonly step: "idle" }
23
+ | { readonly step: "authenticating"; readonly login: 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 login: string; readonly error: FlowError }
32
+ | {
33
+ readonly step: "totpError";
34
+ readonly challengeToken: string;
35
+ readonly expiresIn: number;
36
+ readonly error: FlowError;
37
+ }
38
+ | { readonly step: "totpLocked"; readonly error: FlowError };
39
+
40
+ export interface TotpProof {
41
+ readonly code?: string;
42
+ readonly backup_code?: string;
43
+ }
44
+
45
+ export interface PasswordLoginFlow {
46
+ readonly machine: FlowMachine<PasswordLoginState>;
47
+ login(loginId: string, password: string): Promise<void>;
48
+ submitTotp(proof: TotpProof): Promise<void>;
49
+ reset(): void;
50
+ }
51
+
52
+ export interface PasswordLoginFlowDeps {
53
+ readonly api: AuthApi;
54
+ readonly analytics?: Analytics | null;
55
+ readonly onAuthenticated?: (result: AuthResponse) => void;
56
+ }
57
+
58
+ const LOCKED_STATUS = 423;
59
+
60
+ export function createPasswordLoginFlow(
61
+ deps: PasswordLoginFlowDeps
62
+ ): PasswordLoginFlow {
63
+ const machine = createFlowMachine<PasswordLoginState>({
64
+ id: "auth.password_login",
65
+ initial: { step: "idle" },
66
+ analytics: deps.analytics ?? null,
67
+ });
68
+
69
+ async function login(loginId: string, password: string): Promise<void> {
70
+ await machine.run(
71
+ { step: "authenticating", login: loginId },
72
+ () => deps.api.passwordLogin(loginId, password),
73
+ {
74
+ resolve: (r): PasswordLoginState => {
75
+ if (isTotpChallenge(r)) {
76
+ return {
77
+ step: "totpRequired",
78
+ challengeToken: r.challenge_token,
79
+ expiresIn: r.expires_in,
80
+ };
81
+ }
82
+ deps.onAuthenticated?.(r);
83
+ return { step: "authenticated", result: r };
84
+ },
85
+ reject: (error): PasswordLoginState => ({
86
+ step: "error",
87
+ login: loginId,
88
+ error: toFlowError(error),
89
+ }),
90
+ }
91
+ );
92
+ }
93
+
94
+ async function submitTotp(proof: TotpProof): Promise<void> {
95
+ const s = machine.getState();
96
+ if (s.step !== "totpRequired" && s.step !== "totpError") return;
97
+ const { challengeToken, expiresIn } = s;
98
+ await machine.run(
99
+ { step: "verifyingTotp", challengeToken, expiresIn },
100
+ () => deps.api.totpChallengeVerify(challengeToken, proof),
101
+ {
102
+ resolve: (result): PasswordLoginState => {
103
+ deps.onAuthenticated?.(result);
104
+ return { step: "authenticated", result };
105
+ },
106
+ reject: (error): PasswordLoginState => {
107
+ const flowError = toFlowError(error);
108
+ if (flowError.status === LOCKED_STATUS) {
109
+ return { step: "totpLocked", error: flowError };
110
+ }
111
+ return { step: "totpError", challengeToken, expiresIn, error: flowError };
112
+ },
113
+ }
114
+ );
115
+ }
116
+
117
+ function reset(): void {
118
+ machine.to({ step: "idle" });
119
+ }
120
+
121
+ return { machine, login, submitTotp, reset };
122
+ }