@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,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
|
+
}
|