@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.
- package/CHANGELOG.md +56 -0
- package/LICENSE +21 -0
- package/MODULE.md +147 -0
- package/README.md +116 -0
- package/dist/api/authApi.d.ts +68 -0
- package/dist/api/authApi.d.ts.map +1 -0
- package/dist/api/authApi.js +90 -0
- package/dist/api/authApi.js.map +1 -0
- package/dist/api/types.d.ts +238 -0
- package/dist/api/types.d.ts.map +1 -0
- package/dist/api/types.js +14 -0
- package/dist/api/types.js.map +1 -0
- package/dist/api/urls.d.ts +41 -0
- package/dist/api/urls.d.ts.map +1 -0
- package/dist/api/urls.js +86 -0
- package/dist/api/urls.js.map +1 -0
- package/dist/flows/anonymousFlow.d.ts +33 -0
- package/dist/flows/anonymousFlow.d.ts.map +1 -0
- package/dist/flows/anonymousFlow.js +26 -0
- package/dist/flows/anonymousFlow.js.map +1 -0
- package/dist/flows/authenticatorChangeFlow.d.ts +67 -0
- package/dist/flows/authenticatorChangeFlow.d.ts.map +1 -0
- package/dist/flows/authenticatorChangeFlow.js +79 -0
- package/dist/flows/authenticatorChangeFlow.js.map +1 -0
- package/dist/flows/createFlowMachine.d.ts +55 -0
- package/dist/flows/createFlowMachine.d.ts.map +1 -0
- package/dist/flows/createFlowMachine.js +56 -0
- package/dist/flows/createFlowMachine.js.map +1 -0
- package/dist/flows/errors.d.ts +15 -0
- package/dist/flows/errors.d.ts.map +1 -0
- package/dist/flows/errors.js +17 -0
- package/dist/flows/errors.js.map +1 -0
- package/dist/flows/magicLinkFlow.d.ts +41 -0
- package/dist/flows/magicLinkFlow.d.ts.map +1 -0
- package/dist/flows/magicLinkFlow.js +29 -0
- package/dist/flows/magicLinkFlow.js.map +1 -0
- package/dist/flows/oauthFlow.d.ts +58 -0
- package/dist/flows/oauthFlow.d.ts.map +1 -0
- package/dist/flows/oauthFlow.js +53 -0
- package/dist/flows/oauthFlow.js.map +1 -0
- package/dist/flows/otpFlow.d.ts +74 -0
- package/dist/flows/otpFlow.d.ts.map +1 -0
- package/dist/flows/otpFlow.js +68 -0
- package/dist/flows/otpFlow.js.map +1 -0
- package/dist/flows/passkeyFlow.d.ts +75 -0
- package/dist/flows/passkeyFlow.d.ts.map +1 -0
- package/dist/flows/passkeyFlow.js +100 -0
- package/dist/flows/passkeyFlow.js.map +1 -0
- package/dist/flows/passwordChangeFlow.d.ts +53 -0
- package/dist/flows/passwordChangeFlow.d.ts.map +1 -0
- package/dist/flows/passwordChangeFlow.js +51 -0
- package/dist/flows/passwordChangeFlow.js.map +1 -0
- package/dist/flows/passwordLoginFlow.d.ts +62 -0
- package/dist/flows/passwordLoginFlow.d.ts.map +1 -0
- package/dist/flows/passwordLoginFlow.js +55 -0
- package/dist/flows/passwordLoginFlow.js.map +1 -0
- package/dist/flows/passwordResetFlow.d.ts +56 -0
- package/dist/flows/passwordResetFlow.d.ts.map +1 -0
- package/dist/flows/passwordResetFlow.js +57 -0
- package/dist/flows/passwordResetFlow.js.map +1 -0
- package/dist/flows/qrLoginFlow.d.ts +55 -0
- package/dist/flows/qrLoginFlow.d.ts.map +1 -0
- package/dist/flows/qrLoginFlow.js +91 -0
- package/dist/flows/qrLoginFlow.js.map +1 -0
- package/dist/flows/ssoFlow.d.ts +46 -0
- package/dist/flows/ssoFlow.d.ts.map +1 -0
- package/dist/flows/ssoFlow.js +34 -0
- package/dist/flows/ssoFlow.js.map +1 -0
- package/dist/flows/totpSetupFlow.d.ts +49 -0
- package/dist/flows/totpSetupFlow.d.ts.map +1 -0
- package/dist/flows/totpSetupFlow.js +47 -0
- package/dist/flows/totpSetupFlow.js.map +1 -0
- package/dist/flows/useFlow.d.ts +9 -0
- package/dist/flows/useFlow.d.ts.map +1 -0
- package/dist/flows/useFlow.js +11 -0
- package/dist/flows/useFlow.js.map +1 -0
- package/dist/flows/verificationFlow.d.ts +108 -0
- package/dist/flows/verificationFlow.d.ts.map +1 -0
- package/dist/flows/verificationFlow.js +195 -0
- package/dist/flows/verificationFlow.js.map +1 -0
- package/dist/headless/AuthProvider.d.ts +18 -0
- package/dist/headless/AuthProvider.d.ts.map +1 -0
- package/dist/headless/AuthProvider.js +22 -0
- package/dist/headless/AuthProvider.js.map +1 -0
- package/dist/headless/Passkey.d.ts +31 -0
- package/dist/headless/Passkey.d.ts.map +1 -0
- package/dist/headless/Passkey.js +51 -0
- package/dist/headless/Passkey.js.map +1 -0
- package/dist/headless/PasswordChange.d.ts +20 -0
- package/dist/headless/PasswordChange.d.ts.map +1 -0
- package/dist/headless/PasswordChange.js +30 -0
- package/dist/headless/PasswordChange.js.map +1 -0
- package/dist/headless/PasswordLogin.d.ts +17 -0
- package/dist/headless/PasswordLogin.d.ts.map +1 -0
- package/dist/headless/PasswordLogin.js +31 -0
- package/dist/headless/PasswordLogin.js.map +1 -0
- package/dist/headless/PasswordReset.d.ts +19 -0
- package/dist/headless/PasswordReset.d.ts.map +1 -0
- package/dist/headless/PasswordReset.js +34 -0
- package/dist/headless/PasswordReset.js.map +1 -0
- package/dist/headless/PasswordlessLogin.d.ts +28 -0
- package/dist/headless/PasswordlessLogin.d.ts.map +1 -0
- package/dist/headless/PasswordlessLogin.js +42 -0
- package/dist/headless/PasswordlessLogin.js.map +1 -0
- package/dist/headless/QrLogin.d.ts +19 -0
- package/dist/headless/QrLogin.d.ts.map +1 -0
- package/dist/headless/QrLogin.js +32 -0
- package/dist/headless/QrLogin.js.map +1 -0
- package/dist/headless/TotpSetup.d.ts +17 -0
- package/dist/headless/TotpSetup.d.ts.map +1 -0
- package/dist/headless/TotpSetup.js +26 -0
- package/dist/headless/TotpSetup.js.map +1 -0
- package/dist/headless/VerificationChallenge.d.ts +37 -0
- package/dist/headless/VerificationChallenge.d.ts.map +1 -0
- package/dist/headless/VerificationChallenge.js +40 -0
- package/dist/headless/VerificationChallenge.js.map +1 -0
- package/dist/headless/misc.d.ts +47 -0
- package/dist/headless/misc.d.ts.map +1 -0
- package/dist/headless/misc.js +84 -0
- package/dist/headless/misc.js.map +1 -0
- package/dist/i18n/keys.d.ts +34 -0
- package/dist/i18n/keys.d.ts.map +1 -0
- package/dist/i18n/keys.js +83 -0
- package/dist/i18n/keys.js.map +1 -0
- package/dist/index.d.ts +73 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +48 -0
- package/dist/index.js.map +1 -0
- package/dist/model/context.d.ts +22 -0
- package/dist/model/context.d.ts.map +1 -0
- package/dist/model/context.js +34 -0
- package/dist/model/context.js.map +1 -0
- package/dist/model/mutations.d.ts +28 -0
- package/dist/model/mutations.d.ts.map +1 -0
- package/dist/model/mutations.js +108 -0
- package/dist/model/mutations.js.map +1 -0
- package/dist/model/queries.d.ts +30 -0
- package/dist/model/queries.d.ts.map +1 -0
- package/dist/model/queries.js +87 -0
- package/dist/model/queries.js.map +1 -0
- package/dist/model/queryKeys.d.ts +13 -0
- package/dist/model/queryKeys.d.ts.map +1 -0
- package/dist/model/queryKeys.js +21 -0
- package/dist/model/queryKeys.js.map +1 -0
- package/dist/model/runtime.d.ts +39 -0
- package/dist/model/runtime.d.ts.map +1 -0
- package/dist/model/runtime.js +44 -0
- package/dist/model/runtime.js.map +1 -0
- package/dist/model/session.d.ts +50 -0
- package/dist/model/session.d.ts.map +1 -0
- package/dist/model/session.js +124 -0
- package/dist/model/session.js.map +1 -0
- package/package.json +68 -0
- package/src/api/authApi.ts +332 -0
- package/src/api/types.ts +291 -0
- package/src/api/urls.ts +99 -0
- package/src/flows/anonymousFlow.ts +57 -0
- package/src/flows/authenticatorChangeFlow.ts +160 -0
- package/src/flows/createFlowMachine.ts +126 -0
- package/src/flows/errors.ts +29 -0
- package/src/flows/magicLinkFlow.ts +68 -0
- package/src/flows/oauthFlow.ts +114 -0
- package/src/flows/otpFlow.ts +156 -0
- package/src/flows/passkeyFlow.ts +191 -0
- package/src/flows/passwordChangeFlow.ts +114 -0
- package/src/flows/passwordLoginFlow.ts +122 -0
- package/src/flows/passwordResetFlow.ts +123 -0
- package/src/flows/qrLoginFlow.ts +158 -0
- package/src/flows/ssoFlow.ts +84 -0
- package/src/flows/totpSetupFlow.ts +96 -0
- package/src/flows/useFlow.ts +16 -0
- package/src/flows/verificationFlow.ts +341 -0
- package/src/headless/AuthProvider.tsx +30 -0
- package/src/headless/Passkey.tsx +97 -0
- package/src/headless/PasswordChange.tsx +46 -0
- package/src/headless/PasswordLogin.tsx +49 -0
- package/src/headless/PasswordReset.tsx +51 -0
- package/src/headless/PasswordlessLogin.tsx +60 -0
- package/src/headless/QrLogin.tsx +52 -0
- package/src/headless/TotpSetup.tsx +40 -0
- package/src/headless/VerificationChallenge.tsx +54 -0
- package/src/headless/misc.tsx +151 -0
- package/src/i18n/keys.ts +94 -0
- package/src/index.ts +229 -0
- package/src/model/context.tsx +51 -0
- package/src/model/mutations.ts +152 -0
- package/src/model/queries.ts +130 -0
- package/src/model/queryKeys.ts +32 -0
- package/src/model/runtime.ts +93 -0
- package/src/model/session.ts +188 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Analytics,
|
|
3
|
+
VerificationChallenge,
|
|
4
|
+
VerificationChallengeHandler,
|
|
5
|
+
VerificationOutcome,
|
|
6
|
+
} from "@stapel/core";
|
|
7
|
+
import type { AuthApi } from "../api/authApi.js";
|
|
8
|
+
import type {
|
|
9
|
+
VerificationEnvelope,
|
|
10
|
+
VerificationFactorId,
|
|
11
|
+
} from "../api/types.js";
|
|
12
|
+
import { createFlowMachine } from "./createFlowMachine.js";
|
|
13
|
+
import type { FlowMachine } from "./createFlowMachine.js";
|
|
14
|
+
import { toFlowError } from "./errors.js";
|
|
15
|
+
import type { FlowError } from "./errors.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* THE FLAGSHIP CROSS-MODULE FLOW (frontend-standard §2).
|
|
19
|
+
*
|
|
20
|
+
* `@stapel/core`'s client intercepts a `403` whose body carries a
|
|
21
|
+
* `verification` envelope and calls `onVerificationChallenge(challenge)`,
|
|
22
|
+
* awaiting a {@link VerificationOutcome}. This controller *is* that handler: it
|
|
23
|
+
* parks a React flow-machine on the challenge, drives the user through one
|
|
24
|
+
* interchangeable factor (`otp_email` / `otp_phone` / `totp` / `passkey`), and
|
|
25
|
+
* resolves the awaited promise with `{ retry: true, token }` so core replays
|
|
26
|
+
* the original request with `X-Verification-Token`. Cancelling resolves
|
|
27
|
+
* `{ retry: false }` and the original 403 propagates.
|
|
28
|
+
*
|
|
29
|
+
* Wire it once:
|
|
30
|
+
* ```ts
|
|
31
|
+
* const verification = createVerificationController({ api });
|
|
32
|
+
* const client = createStapelClient({
|
|
33
|
+
* baseUrl,
|
|
34
|
+
* onVerificationChallenge: verification.handler,
|
|
35
|
+
* });
|
|
36
|
+
* // render <VerificationChallenge controller={verification}> at the app root
|
|
37
|
+
* ```
|
|
38
|
+
*
|
|
39
|
+
* Lifecycle safety: exactly one challenge is live at a time. It self-releases
|
|
40
|
+
* on `expires_at` (an abandoned modal resolves `retry:false` instead of hanging
|
|
41
|
+
* the core request forever and wedging future challenges), and a factor whose
|
|
42
|
+
* `initiate` fails recoverably returns to the picker so a different factor is
|
|
43
|
+
* still choosable — only a 404 (challenge gone) ends the whole challenge.
|
|
44
|
+
*
|
|
45
|
+
* WebAuthn note: the `passkey` factor is **flow-complete but its browser
|
|
46
|
+
* binding is a thin TODO** — the machine surfaces `session_key` + `options`
|
|
47
|
+
* and accepts a finished credential via `submitPasskey`; calling
|
|
48
|
+
* `navigator.credentials.get()` is the host's (or an injected `webauthnGet`)
|
|
49
|
+
* responsibility. See MODULE.md.
|
|
50
|
+
*/
|
|
51
|
+
export type VerificationState =
|
|
52
|
+
| { readonly step: "idle" }
|
|
53
|
+
| {
|
|
54
|
+
readonly step: "picking";
|
|
55
|
+
readonly challenge: VerificationEnvelope;
|
|
56
|
+
/** Set when a prior factor's initiate failed recoverably — pick another. */
|
|
57
|
+
readonly error?: FlowError;
|
|
58
|
+
}
|
|
59
|
+
| {
|
|
60
|
+
readonly step: "initiating";
|
|
61
|
+
readonly challenge: VerificationEnvelope;
|
|
62
|
+
readonly factor: VerificationFactorId;
|
|
63
|
+
}
|
|
64
|
+
| {
|
|
65
|
+
readonly step: "awaitingCode";
|
|
66
|
+
readonly challenge: VerificationEnvelope;
|
|
67
|
+
readonly factor: "otp_email" | "otp_phone" | "totp";
|
|
68
|
+
readonly target: string | null;
|
|
69
|
+
}
|
|
70
|
+
| {
|
|
71
|
+
readonly step: "awaitingPasskey";
|
|
72
|
+
readonly challenge: VerificationEnvelope;
|
|
73
|
+
readonly sessionKey: string;
|
|
74
|
+
readonly options: Record<string, unknown>;
|
|
75
|
+
}
|
|
76
|
+
| {
|
|
77
|
+
readonly step: "verifying";
|
|
78
|
+
readonly challenge: VerificationEnvelope;
|
|
79
|
+
readonly factor: VerificationFactorId;
|
|
80
|
+
}
|
|
81
|
+
| { readonly step: "verified"; readonly token: string }
|
|
82
|
+
| {
|
|
83
|
+
readonly step: "factorError";
|
|
84
|
+
readonly challenge: VerificationEnvelope;
|
|
85
|
+
readonly factor: VerificationFactorId;
|
|
86
|
+
readonly target: string | null;
|
|
87
|
+
readonly error: FlowError;
|
|
88
|
+
}
|
|
89
|
+
| { readonly step: "unavailable"; readonly error: FlowError }
|
|
90
|
+
| { readonly step: "expired" };
|
|
91
|
+
|
|
92
|
+
export interface VerificationController {
|
|
93
|
+
readonly machine: FlowMachine<VerificationState>;
|
|
94
|
+
/** The handler wired into `createStapelClient({ onVerificationChallenge })`. */
|
|
95
|
+
readonly handler: VerificationChallengeHandler;
|
|
96
|
+
/** Choose one of the challenge's factors and initiate it. */
|
|
97
|
+
chooseFactor(factor: VerificationFactorId): Promise<void>;
|
|
98
|
+
/** Submit an OTP/TOTP code (or a TOTP backup code). */
|
|
99
|
+
submitCode(proof: { code?: string; backup_code?: string }): Promise<void>;
|
|
100
|
+
/** Submit a finished WebAuthn assertion for the `passkey` factor. */
|
|
101
|
+
submitPasskey(credential: unknown): Promise<void>;
|
|
102
|
+
/** Abandon the challenge — resolves the awaited outcome with `retry:false`. */
|
|
103
|
+
cancel(): void;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface VerificationControllerDeps {
|
|
107
|
+
/** Lazy-friendly: a thunk breaks the client↔controller wiring cycle. */
|
|
108
|
+
readonly api: AuthApi | (() => AuthApi);
|
|
109
|
+
readonly analytics?: Analytics | null;
|
|
110
|
+
/**
|
|
111
|
+
* Optional WebAuthn binding. When provided, choosing the `passkey` factor
|
|
112
|
+
* auto-calls it with the server `options` and submits the result — the host
|
|
113
|
+
* needs no passkey code. Thin by design; omit to drive it manually.
|
|
114
|
+
*/
|
|
115
|
+
readonly webauthnGet?: (
|
|
116
|
+
options: Record<string, unknown>
|
|
117
|
+
) => Promise<unknown>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const NOT_FOUND_STATUS = 404;
|
|
121
|
+
const LOCKED_STATUS = 423;
|
|
122
|
+
|
|
123
|
+
function asEnvelope(challenge: VerificationChallenge): VerificationEnvelope {
|
|
124
|
+
return {
|
|
125
|
+
challenge_id: challenge.challenge_id,
|
|
126
|
+
scope: typeof challenge.scope === "string" ? challenge.scope : "",
|
|
127
|
+
factors: (challenge.factors ?? []) as readonly VerificationFactorId[],
|
|
128
|
+
expires_at:
|
|
129
|
+
typeof challenge["expires_at"] === "number"
|
|
130
|
+
? (challenge["expires_at"] as number)
|
|
131
|
+
: 0,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function createVerificationController(
|
|
136
|
+
deps: VerificationControllerDeps
|
|
137
|
+
): VerificationController {
|
|
138
|
+
const machine = createFlowMachine<VerificationState>({
|
|
139
|
+
id: "auth.verification",
|
|
140
|
+
initial: { step: "idle" },
|
|
141
|
+
analytics: deps.analytics ?? null,
|
|
142
|
+
});
|
|
143
|
+
const api = (): AuthApi =>
|
|
144
|
+
typeof deps.api === "function" ? deps.api() : deps.api;
|
|
145
|
+
|
|
146
|
+
// The awaited outcome for the in-flight core request. Exactly one challenge
|
|
147
|
+
// is handled at a time; a second arriving while one is active is declined.
|
|
148
|
+
let resolveOutcome: ((outcome: VerificationOutcome) => void) | null = null;
|
|
149
|
+
// Self-release timer: without it, a user who abandons the modal (never
|
|
150
|
+
// cancels) leaves the core `await onVerificationChallenge(...)` hanging
|
|
151
|
+
// forever AND — because `resolveOutcome` stays set — wedges every FUTURE
|
|
152
|
+
// challenge into `retry:false`. The envelope's `expires_at` bounds this.
|
|
153
|
+
let expiryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
154
|
+
|
|
155
|
+
function clearExpiry(): void {
|
|
156
|
+
if (expiryTimer !== null) {
|
|
157
|
+
clearTimeout(expiryTimer);
|
|
158
|
+
expiryTimer = null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function settle(outcome: VerificationOutcome): void {
|
|
163
|
+
clearExpiry();
|
|
164
|
+
const resolve = resolveOutcome;
|
|
165
|
+
resolveOutcome = null;
|
|
166
|
+
resolve?.(outcome);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function scheduleExpiry(envelope: VerificationEnvelope): void {
|
|
170
|
+
clearExpiry();
|
|
171
|
+
if (!envelope.expires_at) return; // no bound advertised
|
|
172
|
+
const fire = (): void => {
|
|
173
|
+
if (resolveOutcome === null) return; // already settled
|
|
174
|
+
machine.to({ step: "expired" });
|
|
175
|
+
settle({ retry: false });
|
|
176
|
+
};
|
|
177
|
+
const delayMs = envelope.expires_at * 1000 - Date.now();
|
|
178
|
+
if (delayMs <= 0) {
|
|
179
|
+
fire();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
expiryTimer = setTimeout(fire, delayMs);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const handler: VerificationChallengeHandler = (challenge) => {
|
|
186
|
+
if (resolveOutcome !== null) {
|
|
187
|
+
// Already busy with another challenge — decline the new one.
|
|
188
|
+
return Promise.resolve({ retry: false });
|
|
189
|
+
}
|
|
190
|
+
const envelope = asEnvelope(challenge);
|
|
191
|
+
return new Promise<VerificationOutcome>((resolve) => {
|
|
192
|
+
resolveOutcome = resolve;
|
|
193
|
+
machine.to({ step: "picking", challenge: envelope });
|
|
194
|
+
scheduleExpiry(envelope);
|
|
195
|
+
});
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
function currentChallenge(): VerificationEnvelope | null {
|
|
199
|
+
const s = machine.getState();
|
|
200
|
+
if ("challenge" in s) return s.challenge;
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function foldUnavailable(error: FlowError): VerificationState | null {
|
|
205
|
+
if (error.status === NOT_FOUND_STATUS || error.status === LOCKED_STATUS) {
|
|
206
|
+
return { step: "unavailable", error };
|
|
207
|
+
}
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function chooseFactor(factor: VerificationFactorId): Promise<void> {
|
|
212
|
+
const challenge = currentChallenge();
|
|
213
|
+
if (challenge === null) return;
|
|
214
|
+
|
|
215
|
+
await machine.run(
|
|
216
|
+
{ step: "initiating", challenge, factor },
|
|
217
|
+
() => api().verificationInitiate(challenge.challenge_id, factor),
|
|
218
|
+
{
|
|
219
|
+
resolve: (r): VerificationState => {
|
|
220
|
+
if (factor === "passkey") {
|
|
221
|
+
const sessionKey = String(r.data["session_key"] ?? "");
|
|
222
|
+
const options = (r.data["options"] ?? {}) as Record<string, unknown>;
|
|
223
|
+
return {
|
|
224
|
+
step: "awaitingPasskey",
|
|
225
|
+
challenge,
|
|
226
|
+
sessionKey,
|
|
227
|
+
options,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
const target =
|
|
231
|
+
typeof r.data["target"] === "string"
|
|
232
|
+
? (r.data["target"] as string)
|
|
233
|
+
: null;
|
|
234
|
+
return { step: "awaitingCode", challenge, factor, target };
|
|
235
|
+
},
|
|
236
|
+
reject: (error): VerificationState => {
|
|
237
|
+
const flowError = toFlowError(error);
|
|
238
|
+
// A 404 means the whole challenge is gone — restart the original
|
|
239
|
+
// action; release the core request.
|
|
240
|
+
if (flowError.status === NOT_FOUND_STATUS) {
|
|
241
|
+
settle({ retry: false });
|
|
242
|
+
return { step: "unavailable", error: flowError };
|
|
243
|
+
}
|
|
244
|
+
// Any other initiate failure (a locked/invalid factor, network) is
|
|
245
|
+
// factor-scoped: keep the challenge alive and return to the picker so
|
|
246
|
+
// the user can choose a DIFFERENT factor. A 423-locked email factor
|
|
247
|
+
// must not kill an available TOTP factor. Do NOT settle — the expiry
|
|
248
|
+
// timer still bounds abandonment.
|
|
249
|
+
return { step: "picking", challenge, error: flowError };
|
|
250
|
+
},
|
|
251
|
+
}
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
// Auto-drive the passkey factor when a binding is injected (thin seam).
|
|
255
|
+
const after = machine.getState();
|
|
256
|
+
if (after.step === "awaitingPasskey" && deps.webauthnGet) {
|
|
257
|
+
try {
|
|
258
|
+
const credential = await deps.webauthnGet(after.options);
|
|
259
|
+
await submitPasskey(credential);
|
|
260
|
+
} catch (error) {
|
|
261
|
+
machine.to({
|
|
262
|
+
step: "factorError",
|
|
263
|
+
challenge,
|
|
264
|
+
factor: "passkey",
|
|
265
|
+
target: null,
|
|
266
|
+
error: toFlowError(error),
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function complete(
|
|
273
|
+
challenge: VerificationEnvelope,
|
|
274
|
+
factor: VerificationFactorId,
|
|
275
|
+
target: string | null,
|
|
276
|
+
body: Record<string, unknown>
|
|
277
|
+
): Promise<void> {
|
|
278
|
+
await machine.run(
|
|
279
|
+
{ step: "verifying", challenge, factor },
|
|
280
|
+
() => api().verificationComplete(challenge.challenge_id, body),
|
|
281
|
+
{
|
|
282
|
+
resolve: (r): VerificationState => {
|
|
283
|
+
settle({ retry: true, token: r.verification_token });
|
|
284
|
+
return { step: "verified", token: r.verification_token };
|
|
285
|
+
},
|
|
286
|
+
reject: (error): VerificationState => {
|
|
287
|
+
const flowError = toFlowError(error);
|
|
288
|
+
const unavailable = foldUnavailable(flowError);
|
|
289
|
+
if (unavailable) {
|
|
290
|
+
settle({ retry: false });
|
|
291
|
+
return unavailable;
|
|
292
|
+
}
|
|
293
|
+
// Recoverable (wrong code) — stay in the flow for a retry.
|
|
294
|
+
return { step: "factorError", challenge, factor, target, error: flowError };
|
|
295
|
+
},
|
|
296
|
+
}
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function submitCode(proof: {
|
|
301
|
+
code?: string;
|
|
302
|
+
backup_code?: string;
|
|
303
|
+
}): Promise<void> {
|
|
304
|
+
const s = machine.getState();
|
|
305
|
+
let challenge: VerificationEnvelope;
|
|
306
|
+
let factor: VerificationFactorId;
|
|
307
|
+
let target: string | null;
|
|
308
|
+
if (s.step === "awaitingCode") {
|
|
309
|
+
({ challenge, factor, target } = s);
|
|
310
|
+
} else if (s.step === "factorError" && s.factor !== "passkey") {
|
|
311
|
+
({ challenge, factor, target } = s);
|
|
312
|
+
} else {
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
await complete(challenge, factor, target, { factor, ...proof });
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function submitPasskey(credential: unknown): Promise<void> {
|
|
319
|
+
const s = machine.getState();
|
|
320
|
+
if (s.step !== "awaitingPasskey") return;
|
|
321
|
+
await complete(s.challenge, "passkey", null, {
|
|
322
|
+
factor: "passkey",
|
|
323
|
+
session_key: s.sessionKey,
|
|
324
|
+
credential,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function cancel(): void {
|
|
329
|
+
settle({ retry: false });
|
|
330
|
+
machine.to({ step: "idle" });
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return {
|
|
334
|
+
machine,
|
|
335
|
+
handler,
|
|
336
|
+
chooseFactor,
|
|
337
|
+
submitCode,
|
|
338
|
+
submitPasskey,
|
|
339
|
+
cancel,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import type { ReactElement, ReactNode } from "react";
|
|
3
|
+
import { AuthRuntimeContext } from "../model/context.js";
|
|
4
|
+
import type { AuthRuntime } from "../model/runtime.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Provides the wired {@link AuthRuntime} to every auth hook and headless
|
|
8
|
+
* component below it, and restores any persisted session once on mount. Bring
|
|
9
|
+
* your own visual shell — this component renders nothing of its own.
|
|
10
|
+
*
|
|
11
|
+
* ```tsx
|
|
12
|
+
* const runtime = createAuthRuntime({ baseUrl: "/auth/api", storage });
|
|
13
|
+
* // give runtime.client to core's <StapelConfigProvider config={{ client }}>
|
|
14
|
+
* <AuthProvider runtime={runtime}>{app}</AuthProvider>
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
export function AuthProvider(props: {
|
|
18
|
+
runtime: AuthRuntime;
|
|
19
|
+
children: ReactNode;
|
|
20
|
+
}): ReactElement {
|
|
21
|
+
const { runtime } = props;
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
void runtime.session.restore();
|
|
24
|
+
}, [runtime]);
|
|
25
|
+
return (
|
|
26
|
+
<AuthRuntimeContext.Provider value={runtime}>
|
|
27
|
+
{props.children}
|
|
28
|
+
</AuthRuntimeContext.Provider>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import {
|
|
4
|
+
createPasskeyLoginFlow,
|
|
5
|
+
createPasskeyRegistrationFlow,
|
|
6
|
+
} from "../flows/passkeyFlow.js";
|
|
7
|
+
import type {
|
|
8
|
+
PasskeyLoginState,
|
|
9
|
+
PasskeyRegisterState,
|
|
10
|
+
} from "../flows/passkeyFlow.js";
|
|
11
|
+
import { useFlow } from "../flows/useFlow.js";
|
|
12
|
+
import { useAuthAnalytics, useAuthApi, useAuthSession } from "../model/context.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Optional WebAuthn binding injected into the passkey headless components.
|
|
16
|
+
* THIN by design (auth-sa.md §17): when omitted, the host performs the
|
|
17
|
+
* `navigator.credentials.*` ceremony and calls `submit*` with the result.
|
|
18
|
+
*/
|
|
19
|
+
export type WebauthnBinding = (
|
|
20
|
+
options: Record<string, unknown>
|
|
21
|
+
) => Promise<unknown>;
|
|
22
|
+
|
|
23
|
+
export interface PasskeyRegistrationBag {
|
|
24
|
+
readonly state: PasskeyRegisterState;
|
|
25
|
+
begin(deviceName?: string): void;
|
|
26
|
+
submitCredential(credential: unknown): void;
|
|
27
|
+
reset(): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Headless passkey registration (auth-sa.md §17, requires auth). */
|
|
31
|
+
export function PasskeyRegistration(props: {
|
|
32
|
+
children: (bag: PasskeyRegistrationBag) => ReactNode;
|
|
33
|
+
webauthnCreate?: WebauthnBinding;
|
|
34
|
+
}): ReactNode {
|
|
35
|
+
const api = useAuthApi();
|
|
36
|
+
const analytics = useAuthAnalytics();
|
|
37
|
+
const { webauthnCreate } = props;
|
|
38
|
+
const flow = useMemo(
|
|
39
|
+
() =>
|
|
40
|
+
createPasskeyRegistrationFlow({
|
|
41
|
+
api,
|
|
42
|
+
analytics,
|
|
43
|
+
...(webauthnCreate !== undefined ? { webauthnCreate } : {}),
|
|
44
|
+
}),
|
|
45
|
+
[api, analytics, webauthnCreate]
|
|
46
|
+
);
|
|
47
|
+
const state = useFlow(flow.machine);
|
|
48
|
+
return props.children({
|
|
49
|
+
state,
|
|
50
|
+
begin: (deviceName) => {
|
|
51
|
+
void flow.begin(deviceName);
|
|
52
|
+
},
|
|
53
|
+
submitCredential: (credential) => {
|
|
54
|
+
void flow.submitCredential(credential);
|
|
55
|
+
},
|
|
56
|
+
reset: flow.reset,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface PasskeyLoginBag {
|
|
61
|
+
readonly state: PasskeyLoginState;
|
|
62
|
+
begin(email?: string): void;
|
|
63
|
+
submitAssertion(credential: unknown): void;
|
|
64
|
+
reset(): void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Headless passkey login (auth-sa.md §17, no auth required). */
|
|
68
|
+
export function PasskeyLogin(props: {
|
|
69
|
+
children: (bag: PasskeyLoginBag) => ReactNode;
|
|
70
|
+
webauthnGet?: WebauthnBinding;
|
|
71
|
+
}): ReactNode {
|
|
72
|
+
const api = useAuthApi();
|
|
73
|
+
const analytics = useAuthAnalytics();
|
|
74
|
+
const session = useAuthSession();
|
|
75
|
+
const { webauthnGet } = props;
|
|
76
|
+
const flow = useMemo(
|
|
77
|
+
() =>
|
|
78
|
+
createPasskeyLoginFlow({
|
|
79
|
+
api,
|
|
80
|
+
analytics,
|
|
81
|
+
onAuthenticated: (r) => session.adopt(r),
|
|
82
|
+
...(webauthnGet !== undefined ? { webauthnGet } : {}),
|
|
83
|
+
}),
|
|
84
|
+
[api, analytics, session, webauthnGet]
|
|
85
|
+
);
|
|
86
|
+
const state = useFlow(flow.machine);
|
|
87
|
+
return props.children({
|
|
88
|
+
state,
|
|
89
|
+
begin: (email) => {
|
|
90
|
+
void flow.begin(email);
|
|
91
|
+
},
|
|
92
|
+
submitAssertion: (credential) => {
|
|
93
|
+
void flow.submitAssertion(credential);
|
|
94
|
+
},
|
|
95
|
+
reset: flow.reset,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import type { OtpChannel } from "../api/types.js";
|
|
4
|
+
import { createPasswordChangeFlow } from "../flows/passwordChangeFlow.js";
|
|
5
|
+
import type { PasswordChangeState } from "../flows/passwordChangeFlow.js";
|
|
6
|
+
import { useFlow } from "../flows/useFlow.js";
|
|
7
|
+
import { useAuthAnalytics, useAuthApi } from "../model/context.js";
|
|
8
|
+
|
|
9
|
+
export interface PasswordChangeBag {
|
|
10
|
+
readonly state: PasswordChangeState;
|
|
11
|
+
changeWithPassword(oldPassword: string, newPassword: string): void;
|
|
12
|
+
requestOtp(method: OtpChannel): void;
|
|
13
|
+
submitOtp(code: string, newPassword: string): void;
|
|
14
|
+
reset(): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Headless authenticated password change (auth-sa.md §4). Pair with
|
|
19
|
+
* `usePasswordMethods()` to decide which tabs to render; call
|
|
20
|
+
* `changeWithPassword` for the old-password tab or `requestOtp`/`submitOtp`
|
|
21
|
+
* for the email/SMS tabs.
|
|
22
|
+
*/
|
|
23
|
+
export function PasswordChange(props: {
|
|
24
|
+
children: (bag: PasswordChangeBag) => ReactNode;
|
|
25
|
+
}): ReactNode {
|
|
26
|
+
const api = useAuthApi();
|
|
27
|
+
const analytics = useAuthAnalytics();
|
|
28
|
+
const flow = useMemo(
|
|
29
|
+
() => createPasswordChangeFlow({ api, analytics }),
|
|
30
|
+
[api, analytics]
|
|
31
|
+
);
|
|
32
|
+
const state = useFlow(flow.machine);
|
|
33
|
+
return props.children({
|
|
34
|
+
state,
|
|
35
|
+
changeWithPassword: (oldPassword, newPassword) => {
|
|
36
|
+
void flow.changeWithPassword(oldPassword, newPassword);
|
|
37
|
+
},
|
|
38
|
+
requestOtp: (method) => {
|
|
39
|
+
void flow.requestOtp(method);
|
|
40
|
+
},
|
|
41
|
+
submitOtp: (code, newPassword) => {
|
|
42
|
+
void flow.submitOtp(code, newPassword);
|
|
43
|
+
},
|
|
44
|
+
reset: flow.reset,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { createPasswordLoginFlow } from "../flows/passwordLoginFlow.js";
|
|
4
|
+
import type {
|
|
5
|
+
PasswordLoginState,
|
|
6
|
+
TotpProof,
|
|
7
|
+
} from "../flows/passwordLoginFlow.js";
|
|
8
|
+
import { useFlow } from "../flows/useFlow.js";
|
|
9
|
+
import { useAuthAnalytics, useAuthApi, useAuthSession } from "../model/context.js";
|
|
10
|
+
|
|
11
|
+
export interface PasswordLoginBag {
|
|
12
|
+
readonly state: PasswordLoginState;
|
|
13
|
+
login(loginId: string, password: string): void;
|
|
14
|
+
submitTotp(proof: TotpProof): void;
|
|
15
|
+
reset(): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Headless password login with the TOTP step-up branch (auth-sa.md §3 + §11).
|
|
20
|
+
* When `state.step === "totpRequired"`, render a TOTP input and call
|
|
21
|
+
* `submitTotp({ code })` (or `{ backup_code }`).
|
|
22
|
+
*/
|
|
23
|
+
export function PasswordLogin(props: {
|
|
24
|
+
children: (bag: PasswordLoginBag) => ReactNode;
|
|
25
|
+
}): ReactNode {
|
|
26
|
+
const api = useAuthApi();
|
|
27
|
+
const analytics = useAuthAnalytics();
|
|
28
|
+
const session = useAuthSession();
|
|
29
|
+
const flow = useMemo(
|
|
30
|
+
() =>
|
|
31
|
+
createPasswordLoginFlow({
|
|
32
|
+
api,
|
|
33
|
+
analytics,
|
|
34
|
+
onAuthenticated: (r) => session.adopt(r),
|
|
35
|
+
}),
|
|
36
|
+
[api, analytics, session]
|
|
37
|
+
);
|
|
38
|
+
const state = useFlow(flow.machine);
|
|
39
|
+
return props.children({
|
|
40
|
+
state,
|
|
41
|
+
login: (loginId, password) => {
|
|
42
|
+
void flow.login(loginId, password);
|
|
43
|
+
},
|
|
44
|
+
submitTotp: (proof) => {
|
|
45
|
+
void flow.submitTotp(proof);
|
|
46
|
+
},
|
|
47
|
+
reset: flow.reset,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import type { OtpChannel } from "../api/types.js";
|
|
4
|
+
import { createPasswordResetFlow } from "../flows/passwordResetFlow.js";
|
|
5
|
+
import type { PasswordResetState } from "../flows/passwordResetFlow.js";
|
|
6
|
+
import { useFlow } from "../flows/useFlow.js";
|
|
7
|
+
import { useAuthAnalytics, useAuthApi, useAuthSession } from "../model/context.js";
|
|
8
|
+
|
|
9
|
+
export interface PasswordResetBag {
|
|
10
|
+
readonly state: PasswordResetState;
|
|
11
|
+
request(channel: OtpChannel, value: string): void;
|
|
12
|
+
resend(): void;
|
|
13
|
+
submit(code: string, newPassword: string): void;
|
|
14
|
+
reset(): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Headless unauthenticated password reset (auth-sa.md §5). The `codeSent`
|
|
19
|
+
* human-wait collects the code AND the new password together, then the user
|
|
20
|
+
* lands in a fresh session (`authenticated`).
|
|
21
|
+
*/
|
|
22
|
+
export function PasswordReset(props: {
|
|
23
|
+
children: (bag: PasswordResetBag) => ReactNode;
|
|
24
|
+
}): ReactNode {
|
|
25
|
+
const api = useAuthApi();
|
|
26
|
+
const analytics = useAuthAnalytics();
|
|
27
|
+
const session = useAuthSession();
|
|
28
|
+
const flow = useMemo(
|
|
29
|
+
() =>
|
|
30
|
+
createPasswordResetFlow({
|
|
31
|
+
api,
|
|
32
|
+
analytics,
|
|
33
|
+
onAuthenticated: (r) => session.adopt(r),
|
|
34
|
+
}),
|
|
35
|
+
[api, analytics, session]
|
|
36
|
+
);
|
|
37
|
+
const state = useFlow(flow.machine);
|
|
38
|
+
return props.children({
|
|
39
|
+
state,
|
|
40
|
+
request: (channel, value) => {
|
|
41
|
+
void flow.request(channel, value);
|
|
42
|
+
},
|
|
43
|
+
resend: () => {
|
|
44
|
+
void flow.resend();
|
|
45
|
+
},
|
|
46
|
+
submit: (code, newPassword) => {
|
|
47
|
+
void flow.submit(code, newPassword);
|
|
48
|
+
},
|
|
49
|
+
reset: flow.reset,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import type { OtpChannel } from "../api/types.js";
|
|
4
|
+
import { createOtpFlow } from "../flows/otpFlow.js";
|
|
5
|
+
import type { OtpState } from "../flows/otpFlow.js";
|
|
6
|
+
import { useFlow } from "../flows/useFlow.js";
|
|
7
|
+
import { useAuthAnalytics, useAuthApi, useAuthSession } from "../model/context.js";
|
|
8
|
+
|
|
9
|
+
/** Render-prop bag for {@link PasswordlessLogin}. */
|
|
10
|
+
export interface PasswordlessLoginBag {
|
|
11
|
+
readonly state: OtpState;
|
|
12
|
+
requestCode(channel: OtpChannel, value: string, captchaToken?: string): void;
|
|
13
|
+
resend(captchaToken?: string): void;
|
|
14
|
+
submitCode(code: string): void;
|
|
15
|
+
reset(): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Headless Email/Phone OTP login (auth-sa.md §1–2). Owns an `otpFlow` machine
|
|
20
|
+
* and hands its state + actions to a render prop — you supply the markup.
|
|
21
|
+
*
|
|
22
|
+
* ```tsx
|
|
23
|
+
* <PasswordlessLogin>
|
|
24
|
+
* {({ state, requestCode, submitCode }) =>
|
|
25
|
+
* state.step === "codeSent"
|
|
26
|
+
* ? <CodeForm onSubmit={submitCode} />
|
|
27
|
+
* : <EmailForm onSubmit={(e) => requestCode("email", e)} />}
|
|
28
|
+
* </PasswordlessLogin>
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export function PasswordlessLogin(props: {
|
|
32
|
+
children: (bag: PasswordlessLoginBag) => ReactNode;
|
|
33
|
+
}): ReactNode {
|
|
34
|
+
const api = useAuthApi();
|
|
35
|
+
const analytics = useAuthAnalytics();
|
|
36
|
+
const session = useAuthSession();
|
|
37
|
+
const flow = useMemo(
|
|
38
|
+
() =>
|
|
39
|
+
createOtpFlow({
|
|
40
|
+
api,
|
|
41
|
+
analytics,
|
|
42
|
+
onAuthenticated: (r) => session.adopt(r),
|
|
43
|
+
}),
|
|
44
|
+
[api, analytics, session]
|
|
45
|
+
);
|
|
46
|
+
const state = useFlow(flow.machine);
|
|
47
|
+
return props.children({
|
|
48
|
+
state,
|
|
49
|
+
requestCode: (channel, value, captchaToken) => {
|
|
50
|
+
void flow.requestCode(channel, value, captchaToken);
|
|
51
|
+
},
|
|
52
|
+
resend: (captchaToken) => {
|
|
53
|
+
void flow.resend(captchaToken);
|
|
54
|
+
},
|
|
55
|
+
submitCode: (code) => {
|
|
56
|
+
void flow.submitCode(code);
|
|
57
|
+
},
|
|
58
|
+
reset: flow.reset,
|
|
59
|
+
});
|
|
60
|
+
}
|