@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,123 @@
|
|
|
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
|
+
* Unauthenticated password reset (auth-sa.md §5). User proves ownership of an
|
|
11
|
+
* email/phone via OTP and receives a fresh session with the new password. The
|
|
12
|
+
* verify step carries the new password, so this is a two-input human-wait
|
|
13
|
+
* (`codeSent`): code + new password together.
|
|
14
|
+
*/
|
|
15
|
+
export type PasswordResetState =
|
|
16
|
+
| { readonly step: "idle" }
|
|
17
|
+
| { readonly step: "requesting"; readonly channel: OtpChannel; readonly value: string }
|
|
18
|
+
| {
|
|
19
|
+
readonly step: "codeSent";
|
|
20
|
+
readonly channel: OtpChannel;
|
|
21
|
+
readonly value: string;
|
|
22
|
+
readonly target: string;
|
|
23
|
+
}
|
|
24
|
+
| { readonly step: "verifying"; readonly channel: OtpChannel; readonly value: string; readonly target: string }
|
|
25
|
+
| { readonly step: "authenticated"; readonly result: AuthResponse }
|
|
26
|
+
| {
|
|
27
|
+
readonly step: "requestError";
|
|
28
|
+
readonly channel: OtpChannel;
|
|
29
|
+
readonly value: string;
|
|
30
|
+
readonly error: FlowError;
|
|
31
|
+
}
|
|
32
|
+
| {
|
|
33
|
+
readonly step: "codeError";
|
|
34
|
+
readonly channel: OtpChannel;
|
|
35
|
+
readonly value: string;
|
|
36
|
+
readonly target: string;
|
|
37
|
+
readonly error: FlowError;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export interface PasswordResetFlow {
|
|
41
|
+
readonly machine: FlowMachine<PasswordResetState>;
|
|
42
|
+
request(channel: OtpChannel, value: string): Promise<void>;
|
|
43
|
+
resend(): Promise<void>;
|
|
44
|
+
submit(code: string, newPassword: string): Promise<void>;
|
|
45
|
+
reset(): void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface PasswordResetFlowDeps {
|
|
49
|
+
readonly api: AuthApi;
|
|
50
|
+
readonly analytics?: Analytics | null;
|
|
51
|
+
readonly onAuthenticated?: (result: AuthResponse) => void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function createPasswordResetFlow(
|
|
55
|
+
deps: PasswordResetFlowDeps
|
|
56
|
+
): PasswordResetFlow {
|
|
57
|
+
const machine = createFlowMachine<PasswordResetState>({
|
|
58
|
+
id: "auth.password_reset",
|
|
59
|
+
initial: { step: "idle" },
|
|
60
|
+
analytics: deps.analytics ?? null,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
async function request(channel: OtpChannel, value: string): Promise<void> {
|
|
64
|
+
await machine.run(
|
|
65
|
+
{ step: "requesting", channel, value },
|
|
66
|
+
() => deps.api.passwordResetRequest(channel, value),
|
|
67
|
+
{
|
|
68
|
+
resolve: (r): PasswordResetState => ({
|
|
69
|
+
step: "codeSent",
|
|
70
|
+
channel,
|
|
71
|
+
value,
|
|
72
|
+
target: r.target,
|
|
73
|
+
}),
|
|
74
|
+
reject: (error): PasswordResetState => ({
|
|
75
|
+
step: "requestError",
|
|
76
|
+
channel,
|
|
77
|
+
value,
|
|
78
|
+
error: toFlowError(error),
|
|
79
|
+
}),
|
|
80
|
+
}
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function resend(): Promise<void> {
|
|
85
|
+
const s = machine.getState();
|
|
86
|
+
if (
|
|
87
|
+
s.step === "codeSent" ||
|
|
88
|
+
s.step === "codeError" ||
|
|
89
|
+
s.step === "requestError"
|
|
90
|
+
) {
|
|
91
|
+
await request(s.channel, s.value);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function submit(code: string, newPassword: string): Promise<void> {
|
|
96
|
+
const s = machine.getState();
|
|
97
|
+
if (s.step !== "codeSent" && s.step !== "codeError") return;
|
|
98
|
+
const { channel, value, target } = s;
|
|
99
|
+
await machine.run(
|
|
100
|
+
{ step: "verifying", channel, value, target },
|
|
101
|
+
() => deps.api.passwordResetVerify(channel, value, code, newPassword),
|
|
102
|
+
{
|
|
103
|
+
resolve: (result): PasswordResetState => {
|
|
104
|
+
deps.onAuthenticated?.(result);
|
|
105
|
+
return { step: "authenticated", result };
|
|
106
|
+
},
|
|
107
|
+
reject: (error): PasswordResetState => ({
|
|
108
|
+
step: "codeError",
|
|
109
|
+
channel,
|
|
110
|
+
value,
|
|
111
|
+
target,
|
|
112
|
+
error: toFlowError(error),
|
|
113
|
+
}),
|
|
114
|
+
}
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function reset(): void {
|
|
119
|
+
machine.to({ step: "idle" });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { machine, request, resend, submit, reset };
|
|
123
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { Analytics } from "@stapel/core";
|
|
2
|
+
import type { AuthApi } from "../api/authApi.js";
|
|
3
|
+
import type { AuthTokens, QrType } 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
|
+
* QR authentication with background polling (auth-sa.md §8). Serves both the
|
|
11
|
+
* `login_request` sign-in tab (polling device receives `access_token` /
|
|
12
|
+
* `refresh_token` on fulfilment) and the `session_share` profile modal (the
|
|
13
|
+
* *scanning* device gets the session; the generator just watches for
|
|
14
|
+
* `fulfilled`).
|
|
15
|
+
*
|
|
16
|
+
* Device binding matters: generate and poll from the same browser context so
|
|
17
|
+
* the httponly `stapel_qr_<key>` cookie is present (the client must send
|
|
18
|
+
* credentials). On `expired` the flow auto-regenerates and resets the poll
|
|
19
|
+
* loop, per spec.
|
|
20
|
+
*/
|
|
21
|
+
export type QrLoginState =
|
|
22
|
+
| { readonly step: "idle" }
|
|
23
|
+
| { readonly step: "generating"; readonly type: QrType }
|
|
24
|
+
| {
|
|
25
|
+
readonly step: "awaitingScan";
|
|
26
|
+
readonly type: QrType;
|
|
27
|
+
readonly key: string;
|
|
28
|
+
readonly scanUrl: string;
|
|
29
|
+
readonly expiresIn: number;
|
|
30
|
+
}
|
|
31
|
+
| { readonly step: "fulfilled"; readonly tokens: AuthTokens | null }
|
|
32
|
+
| { readonly step: "rejected"; readonly key: string }
|
|
33
|
+
| { readonly step: "error"; readonly error: FlowError };
|
|
34
|
+
|
|
35
|
+
export interface QrLoginFlow {
|
|
36
|
+
readonly machine: FlowMachine<QrLoginState>;
|
|
37
|
+
/** Generate a QR and begin the poll loop. */
|
|
38
|
+
start(
|
|
39
|
+
type: QrType,
|
|
40
|
+
redirectUrl: string,
|
|
41
|
+
allowUnauthenticatedScanner?: boolean
|
|
42
|
+
): Promise<void>;
|
|
43
|
+
/** Stop polling and reset to idle (call on modal close / unmount). */
|
|
44
|
+
dispose(): void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface QrLoginFlowDeps {
|
|
48
|
+
readonly api: AuthApi;
|
|
49
|
+
readonly analytics?: Analytics | null;
|
|
50
|
+
/** For `login_request` fulfilment — receives the delivered tokens. */
|
|
51
|
+
readonly onAuthenticated?: (tokens: AuthTokens) => void;
|
|
52
|
+
/** Poll cadence; default 5000 ms (auth-sa.md §8). */
|
|
53
|
+
readonly pollIntervalMs?: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createQrLoginFlow(deps: QrLoginFlowDeps): QrLoginFlow {
|
|
57
|
+
const machine = createFlowMachine<QrLoginState>({
|
|
58
|
+
id: "auth.qr_login",
|
|
59
|
+
initial: { step: "idle" },
|
|
60
|
+
analytics: deps.analytics ?? null,
|
|
61
|
+
});
|
|
62
|
+
const interval = deps.pollIntervalMs ?? 5000;
|
|
63
|
+
|
|
64
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
65
|
+
let params: {
|
|
66
|
+
type: QrType;
|
|
67
|
+
redirectUrl: string;
|
|
68
|
+
allowUnauth: boolean | undefined;
|
|
69
|
+
} | null = null;
|
|
70
|
+
|
|
71
|
+
function clearTimer(): void {
|
|
72
|
+
if (timer !== null) {
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
timer = null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function schedulePoll(key: string): void {
|
|
79
|
+
clearTimer();
|
|
80
|
+
timer = setTimeout(() => {
|
|
81
|
+
void poll(key);
|
|
82
|
+
}, interval);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function poll(key: string): Promise<void> {
|
|
86
|
+
// Bail if the machine moved on (disposed / regenerated).
|
|
87
|
+
const s = machine.getState();
|
|
88
|
+
if (s.step !== "awaitingScan" || s.key !== key) return;
|
|
89
|
+
try {
|
|
90
|
+
const status = await deps.api.qrStatus(key);
|
|
91
|
+
if (machine.getState().step !== "awaitingScan") return;
|
|
92
|
+
switch (status.status) {
|
|
93
|
+
case "pending":
|
|
94
|
+
schedulePoll(key);
|
|
95
|
+
break;
|
|
96
|
+
case "fulfilled": {
|
|
97
|
+
clearTimer();
|
|
98
|
+
const tokens: AuthTokens | null =
|
|
99
|
+
status.access_token !== undefined && status.refresh_token !== undefined
|
|
100
|
+
? { access: status.access_token, refresh: status.refresh_token }
|
|
101
|
+
: null;
|
|
102
|
+
if (tokens) deps.onAuthenticated?.(tokens);
|
|
103
|
+
machine.to({ step: "fulfilled", tokens });
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
case "expired":
|
|
107
|
+
clearTimer();
|
|
108
|
+
if (params) {
|
|
109
|
+
void start(params.type, params.redirectUrl, params.allowUnauth);
|
|
110
|
+
}
|
|
111
|
+
break;
|
|
112
|
+
case "rejected":
|
|
113
|
+
clearTimer();
|
|
114
|
+
machine.to({ step: "rejected", key });
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
} catch (error) {
|
|
118
|
+
clearTimer();
|
|
119
|
+
machine.to({ step: "error", error: toFlowError(error) });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function start(
|
|
124
|
+
type: QrType,
|
|
125
|
+
redirectUrl: string,
|
|
126
|
+
allowUnauthenticatedScanner?: boolean
|
|
127
|
+
): Promise<void> {
|
|
128
|
+
clearTimer();
|
|
129
|
+
params = { type, redirectUrl, allowUnauth: allowUnauthenticatedScanner };
|
|
130
|
+
await machine.run(
|
|
131
|
+
{ step: "generating", type },
|
|
132
|
+
() => deps.api.qrGenerate(type, redirectUrl, allowUnauthenticatedScanner),
|
|
133
|
+
{
|
|
134
|
+
resolve: (r): QrLoginState => ({
|
|
135
|
+
step: "awaitingScan",
|
|
136
|
+
type,
|
|
137
|
+
key: r.key,
|
|
138
|
+
scanUrl: r.scan_url,
|
|
139
|
+
expiresIn: r.expires_in,
|
|
140
|
+
}),
|
|
141
|
+
reject: (error): QrLoginState => ({
|
|
142
|
+
step: "error",
|
|
143
|
+
error: toFlowError(error),
|
|
144
|
+
}),
|
|
145
|
+
}
|
|
146
|
+
);
|
|
147
|
+
const after = machine.getState();
|
|
148
|
+
if (after.step === "awaitingScan") schedulePoll(after.key);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function dispose(): void {
|
|
152
|
+
clearTimer();
|
|
153
|
+
params = null;
|
|
154
|
+
machine.to({ step: "idle" });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return { machine, start, dispose };
|
|
158
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { Analytics } from "@stapel/core";
|
|
2
|
+
import type { AuthApi } from "../api/authApi.js";
|
|
3
|
+
import type { SsoLookupResponse } from "../api/types.js";
|
|
4
|
+
import { authUrls } from "../api/urls.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
|
+
* Enterprise SSO discovery (auth-sa.md §18). The flow covers the *frontend*
|
|
12
|
+
* part: look up a domain after the user types their email, then decide what to
|
|
13
|
+
* render. Actual login is a full-page browser navigation to
|
|
14
|
+
* `authUrls(base).ssoLogin(orgSlug)` — the backend handles SAML/OIDC opaquely
|
|
15
|
+
* and drops the user back on `FRONTEND_URL/` with cookies set. `beginLogin`
|
|
16
|
+
* performs that navigation when a redirector is available.
|
|
17
|
+
*/
|
|
18
|
+
export type SsoState =
|
|
19
|
+
| { readonly step: "idle" }
|
|
20
|
+
| { readonly step: "looking"; readonly domain: string }
|
|
21
|
+
| {
|
|
22
|
+
readonly step: "resolved";
|
|
23
|
+
readonly domain: string;
|
|
24
|
+
readonly result: SsoLookupResponse;
|
|
25
|
+
}
|
|
26
|
+
| { readonly step: "error"; readonly domain: string; readonly error: FlowError };
|
|
27
|
+
|
|
28
|
+
export interface SsoFlow {
|
|
29
|
+
readonly machine: FlowMachine<SsoState>;
|
|
30
|
+
/** Look up SSO for an email domain (`sso_required` / optional / none). */
|
|
31
|
+
lookup(domain: string): Promise<void>;
|
|
32
|
+
/** Navigate the browser to the SSO login endpoint for an org slug. */
|
|
33
|
+
beginLogin(orgSlug: string): void;
|
|
34
|
+
reset(): void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface SsoFlowDeps {
|
|
38
|
+
readonly api: AuthApi;
|
|
39
|
+
readonly analytics?: Analytics | null;
|
|
40
|
+
/**
|
|
41
|
+
* Full-page redirect seam (default `window.location.assign`). Injectable for
|
|
42
|
+
* tests / custom routers.
|
|
43
|
+
*/
|
|
44
|
+
readonly redirect?: (url: string) => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createSsoFlow(deps: SsoFlowDeps): SsoFlow {
|
|
48
|
+
const machine = createFlowMachine<SsoState>({
|
|
49
|
+
id: "auth.sso",
|
|
50
|
+
initial: { step: "idle" },
|
|
51
|
+
analytics: deps.analytics ?? null,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
async function lookup(domain: string): Promise<void> {
|
|
55
|
+
await machine.run(
|
|
56
|
+
{ step: "looking", domain },
|
|
57
|
+
() => deps.api.ssoLookup(domain),
|
|
58
|
+
{
|
|
59
|
+
resolve: (result): SsoState => ({ step: "resolved", domain, result }),
|
|
60
|
+
reject: (error): SsoState => ({
|
|
61
|
+
step: "error",
|
|
62
|
+
domain,
|
|
63
|
+
error: toFlowError(error),
|
|
64
|
+
}),
|
|
65
|
+
}
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function beginLogin(orgSlug: string): void {
|
|
70
|
+
const url = authUrls(deps.api.client.baseUrl).ssoLogin(orgSlug);
|
|
71
|
+
const go =
|
|
72
|
+
deps.redirect ??
|
|
73
|
+
((target: string) => {
|
|
74
|
+
if (typeof window !== "undefined") window.location.assign(target);
|
|
75
|
+
});
|
|
76
|
+
go(url);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function reset(): void {
|
|
80
|
+
machine.to({ step: "idle" });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { machine, lookup, beginLogin, reset };
|
|
84
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { Analytics } from "@stapel/core";
|
|
2
|
+
import type { AuthApi } from "../api/authApi.js";
|
|
3
|
+
import { createFlowMachine } from "./createFlowMachine.js";
|
|
4
|
+
import type { FlowMachine } from "./createFlowMachine.js";
|
|
5
|
+
import { toFlowError } from "./errors.js";
|
|
6
|
+
import type { FlowError } from "./errors.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* TOTP enrollment on the security-settings screen (auth-sa.md §11 "TOTP
|
|
10
|
+
* setup"). `start()` mints a pending secret + `otpauth://` URI (render as a
|
|
11
|
+
* QR); `confirm(code)` proves the authenticator and returns the one-time
|
|
12
|
+
* backup codes (shown ONCE — host must surface a copy/warn affordance).
|
|
13
|
+
*/
|
|
14
|
+
export type TotpSetupState =
|
|
15
|
+
| { readonly step: "idle" }
|
|
16
|
+
| { readonly step: "starting" }
|
|
17
|
+
| {
|
|
18
|
+
readonly step: "enrolling";
|
|
19
|
+
readonly secret: string;
|
|
20
|
+
readonly qrUri: string;
|
|
21
|
+
readonly expiresIn: number;
|
|
22
|
+
}
|
|
23
|
+
| { readonly step: "confirming"; readonly secret: string; readonly qrUri: string; readonly expiresIn: number }
|
|
24
|
+
| { readonly step: "done"; readonly backupCodes: readonly string[] }
|
|
25
|
+
| { readonly step: "startError"; readonly error: FlowError }
|
|
26
|
+
| {
|
|
27
|
+
readonly step: "confirmError";
|
|
28
|
+
readonly secret: string;
|
|
29
|
+
readonly qrUri: string;
|
|
30
|
+
readonly expiresIn: number;
|
|
31
|
+
readonly error: FlowError;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export interface TotpSetupFlow {
|
|
35
|
+
readonly machine: FlowMachine<TotpSetupState>;
|
|
36
|
+
start(): Promise<void>;
|
|
37
|
+
confirm(code: string): Promise<void>;
|
|
38
|
+
reset(): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface TotpSetupFlowDeps {
|
|
42
|
+
readonly api: AuthApi;
|
|
43
|
+
readonly analytics?: Analytics | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createTotpSetupFlow(deps: TotpSetupFlowDeps): TotpSetupFlow {
|
|
47
|
+
const machine = createFlowMachine<TotpSetupState>({
|
|
48
|
+
id: "auth.totp_setup",
|
|
49
|
+
initial: { step: "idle" },
|
|
50
|
+
analytics: deps.analytics ?? null,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
async function start(): Promise<void> {
|
|
54
|
+
await machine.run({ step: "starting" }, () => deps.api.totpSetup(), {
|
|
55
|
+
resolve: (r): TotpSetupState => ({
|
|
56
|
+
step: "enrolling",
|
|
57
|
+
secret: r.secret,
|
|
58
|
+
qrUri: r.qr_uri,
|
|
59
|
+
expiresIn: r.expires_in,
|
|
60
|
+
}),
|
|
61
|
+
reject: (error): TotpSetupState => ({
|
|
62
|
+
step: "startError",
|
|
63
|
+
error: toFlowError(error),
|
|
64
|
+
}),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function confirm(code: string): Promise<void> {
|
|
69
|
+
const s = machine.getState();
|
|
70
|
+
if (s.step !== "enrolling" && s.step !== "confirmError") return;
|
|
71
|
+
const { secret, qrUri, expiresIn } = s;
|
|
72
|
+
await machine.run(
|
|
73
|
+
{ step: "confirming", secret, qrUri, expiresIn },
|
|
74
|
+
() => deps.api.totpSetupConfirm(code),
|
|
75
|
+
{
|
|
76
|
+
resolve: (r): TotpSetupState => ({
|
|
77
|
+
step: "done",
|
|
78
|
+
backupCodes: r.backup_codes,
|
|
79
|
+
}),
|
|
80
|
+
reject: (error): TotpSetupState => ({
|
|
81
|
+
step: "confirmError",
|
|
82
|
+
secret,
|
|
83
|
+
qrUri,
|
|
84
|
+
expiresIn,
|
|
85
|
+
error: toFlowError(error),
|
|
86
|
+
}),
|
|
87
|
+
}
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function reset(): void {
|
|
92
|
+
machine.to({ step: "idle" });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { machine, start, confirm, reset };
|
|
96
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
import type { FlowMachine, FlowStateBase } from "./createFlowMachine.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* React binding for a {@link FlowMachine}: subscribes to transitions and
|
|
6
|
+
* returns the current state. Because the machine stores immutable state
|
|
7
|
+
* snapshots, `getState` is a stable reference read — no tearing under
|
|
8
|
+
* concurrent React.
|
|
9
|
+
*/
|
|
10
|
+
export function useFlow<S extends FlowStateBase>(machine: FlowMachine<S>): S {
|
|
11
|
+
return useSyncExternalStore(
|
|
12
|
+
machine.subscribe,
|
|
13
|
+
machine.getState,
|
|
14
|
+
machine.getState
|
|
15
|
+
);
|
|
16
|
+
}
|