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