@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,152 @@
|
|
|
1
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import type {
|
|
3
|
+
UseMutationOptions,
|
|
4
|
+
UseMutationResult,
|
|
5
|
+
} from "@tanstack/react-query";
|
|
6
|
+
import type { StapelApiError } from "@stapel/core";
|
|
7
|
+
import type {
|
|
8
|
+
OtpChannel,
|
|
9
|
+
StatusResponse,
|
|
10
|
+
TotpDisableRequest,
|
|
11
|
+
} from "../api/types.js";
|
|
12
|
+
import { useAuthApi, useAuthSession } from "./context.js";
|
|
13
|
+
import { authQueryKeys } from "./queryKeys.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Write hooks with cache invalidation (frontend-standard §2 — "мутации с
|
|
17
|
+
* инвалидацией"). Each invalidates exactly the keys its effect touches so the
|
|
18
|
+
* security screen / session list stay consistent without a manual refetch.
|
|
19
|
+
*
|
|
20
|
+
* Note: options are built as typed `UseMutationOptions` objects rather than
|
|
21
|
+
* `useMutation<…>()` call-site generics — that keeps `void` (no data / no
|
|
22
|
+
* variables) in *type-reference* position, which `no-invalid-void-type`
|
|
23
|
+
* permits, while call-expression type arguments do not.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Explicit logout: revoke server-side, tear down the session, drop auth caches. */
|
|
27
|
+
export function useLogout(): UseMutationResult<void, StapelApiError, void> {
|
|
28
|
+
const session = useAuthSession();
|
|
29
|
+
const queryClient = useQueryClient();
|
|
30
|
+
const options: UseMutationOptions<void, StapelApiError, void> = {
|
|
31
|
+
mutationFn: () => session.logout(),
|
|
32
|
+
onSuccess: () => {
|
|
33
|
+
queryClient.removeQueries({ queryKey: authQueryKeys.all });
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
return useMutation(options);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Revoke one session (auth-sa.md §12). Immediate — token blacklisted. */
|
|
40
|
+
export function useRevokeSession(): UseMutationResult<
|
|
41
|
+
StatusResponse,
|
|
42
|
+
StapelApiError,
|
|
43
|
+
string
|
|
44
|
+
> {
|
|
45
|
+
const api = useAuthApi();
|
|
46
|
+
const queryClient = useQueryClient();
|
|
47
|
+
const options: UseMutationOptions<StatusResponse, StapelApiError, string> = {
|
|
48
|
+
mutationFn: (id) => api.revokeSession(id),
|
|
49
|
+
onSuccess: () => {
|
|
50
|
+
void queryClient.invalidateQueries({ queryKey: authQueryKeys.sessions() });
|
|
51
|
+
void queryClient.invalidateQueries({
|
|
52
|
+
queryKey: authQueryKeys.securityStatus(),
|
|
53
|
+
});
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
return useMutation(options);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Revoke all sessions except the current one (auth-sa.md §12). */
|
|
60
|
+
export function useRevokeOtherSessions(): UseMutationResult<
|
|
61
|
+
StatusResponse,
|
|
62
|
+
StapelApiError,
|
|
63
|
+
void
|
|
64
|
+
> {
|
|
65
|
+
const api = useAuthApi();
|
|
66
|
+
const queryClient = useQueryClient();
|
|
67
|
+
const options: UseMutationOptions<StatusResponse, StapelApiError, void> = {
|
|
68
|
+
mutationFn: () => api.revokeOtherSessions(),
|
|
69
|
+
onSuccess: () => {
|
|
70
|
+
void queryClient.invalidateQueries({ queryKey: authQueryKeys.sessions() });
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
return useMutation(options);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Clear the `is_suspicious` flag ("This was me"). Idempotent (auth-sa.md §12). */
|
|
77
|
+
export function useConfirmSession(): UseMutationResult<
|
|
78
|
+
StatusResponse,
|
|
79
|
+
StapelApiError,
|
|
80
|
+
string
|
|
81
|
+
> {
|
|
82
|
+
const api = useAuthApi();
|
|
83
|
+
const queryClient = useQueryClient();
|
|
84
|
+
const options: UseMutationOptions<StatusResponse, StapelApiError, string> = {
|
|
85
|
+
mutationFn: (id) => api.confirmSession(id),
|
|
86
|
+
onSuccess: () => {
|
|
87
|
+
void queryClient.invalidateQueries({ queryKey: authQueryKeys.sessions() });
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
return useMutation(options);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Remove a passkey (auth-sa.md §17). Guard against `last_auth_method`. */
|
|
94
|
+
export function useRemovePasskey(): UseMutationResult<
|
|
95
|
+
void,
|
|
96
|
+
StapelApiError,
|
|
97
|
+
string
|
|
98
|
+
> {
|
|
99
|
+
const api = useAuthApi();
|
|
100
|
+
const queryClient = useQueryClient();
|
|
101
|
+
const options: UseMutationOptions<void, StapelApiError, string> = {
|
|
102
|
+
mutationFn: (id) => api.passkeyRemove(id),
|
|
103
|
+
onSuccess: () => {
|
|
104
|
+
void queryClient.invalidateQueries({ queryKey: authQueryKeys.passkeys() });
|
|
105
|
+
void queryClient.invalidateQueries({
|
|
106
|
+
queryKey: authQueryKeys.securityStatus(),
|
|
107
|
+
});
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
return useMutation(options);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Disable TOTP via authenticator/backup/SMS recovery (auth-sa.md §11). */
|
|
114
|
+
export function useDisableTotp(): UseMutationResult<
|
|
115
|
+
StatusResponse,
|
|
116
|
+
StapelApiError,
|
|
117
|
+
TotpDisableRequest
|
|
118
|
+
> {
|
|
119
|
+
const api = useAuthApi();
|
|
120
|
+
const queryClient = useQueryClient();
|
|
121
|
+
const options: UseMutationOptions<
|
|
122
|
+
StatusResponse,
|
|
123
|
+
StapelApiError,
|
|
124
|
+
TotpDisableRequest
|
|
125
|
+
> = {
|
|
126
|
+
mutationFn: (request) => api.totpDisable(request),
|
|
127
|
+
onSuccess: () => {
|
|
128
|
+
void queryClient.invalidateQueries({
|
|
129
|
+
queryKey: authQueryKeys.securityStatus(),
|
|
130
|
+
});
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
return useMutation(options);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Cancel a pending delayed authenticator change (auth-sa.md §9). */
|
|
137
|
+
export function useCancelDelayedChange(
|
|
138
|
+
channel: OtpChannel
|
|
139
|
+
): UseMutationResult<StatusResponse, StapelApiError, string> {
|
|
140
|
+
const api = useAuthApi();
|
|
141
|
+
const queryClient = useQueryClient();
|
|
142
|
+
const options: UseMutationOptions<StatusResponse, StapelApiError, string> = {
|
|
143
|
+
mutationFn: (changeRequestId) =>
|
|
144
|
+
api.changeDelayedCancel(channel, changeRequestId),
|
|
145
|
+
onSuccess: () => {
|
|
146
|
+
void queryClient.invalidateQueries({
|
|
147
|
+
queryKey: authQueryKeys.delayedChange(channel),
|
|
148
|
+
});
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
return useMutation(options);
|
|
152
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { useQuery } from "@tanstack/react-query";
|
|
2
|
+
import type { UseQueryResult } from "@tanstack/react-query";
|
|
3
|
+
import type { StapelApiError } from "@stapel/core";
|
|
4
|
+
import type {
|
|
5
|
+
AuditPage,
|
|
6
|
+
AuthSession as AuthSessionRecord,
|
|
7
|
+
Capabilities,
|
|
8
|
+
DelayedChangeStatus,
|
|
9
|
+
OtpChannel,
|
|
10
|
+
Passkey,
|
|
11
|
+
PasswordMethods,
|
|
12
|
+
SecurityStatus,
|
|
13
|
+
SsoLookupResponse,
|
|
14
|
+
StapelUser,
|
|
15
|
+
} from "../api/types.js";
|
|
16
|
+
import { useAuthApi } from "./context.js";
|
|
17
|
+
import { authQueryKeys } from "./queryKeys.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Read hooks over the auth API. Staleness follows core's query defaults;
|
|
21
|
+
* override per call site via `options` where a page needs fresher data (e.g.
|
|
22
|
+
* `sessions` after a revoke). Keys are namespaced (see `authQueryKeys`).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Login-method feature matrix — call on the sign-in page (auth-sa.md §"capabilities"). */
|
|
26
|
+
export function useCapabilities(): UseQueryResult<Capabilities, StapelApiError> {
|
|
27
|
+
const api = useAuthApi();
|
|
28
|
+
return useQuery({
|
|
29
|
+
queryKey: authQueryKeys.capabilities(),
|
|
30
|
+
queryFn: () => api.capabilities(),
|
|
31
|
+
staleTime: 5 * 60_000,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Current user (auth-sa.md §14). Enabled only when a session exists. */
|
|
36
|
+
export function useMe(
|
|
37
|
+
enabled = true
|
|
38
|
+
): UseQueryResult<StapelUser, StapelApiError> {
|
|
39
|
+
const api = useAuthApi();
|
|
40
|
+
return useQuery({
|
|
41
|
+
queryKey: authQueryKeys.me(),
|
|
42
|
+
queryFn: () => api.me(),
|
|
43
|
+
enabled,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Security settings snapshot (auth-sa.md §10). */
|
|
48
|
+
export function useSecurityStatus(): UseQueryResult<
|
|
49
|
+
SecurityStatus,
|
|
50
|
+
StapelApiError
|
|
51
|
+
> {
|
|
52
|
+
const api = useAuthApi();
|
|
53
|
+
return useQuery({
|
|
54
|
+
queryKey: authQueryKeys.securityStatus(),
|
|
55
|
+
queryFn: () => api.securityStatus(),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Password-change tabs (auth-sa.md §4). */
|
|
60
|
+
export function usePasswordMethods(): UseQueryResult<
|
|
61
|
+
PasswordMethods,
|
|
62
|
+
StapelApiError
|
|
63
|
+
> {
|
|
64
|
+
const api = useAuthApi();
|
|
65
|
+
return useQuery({
|
|
66
|
+
queryKey: authQueryKeys.passwordMethods(),
|
|
67
|
+
queryFn: () => api.passwordMethods(),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Active sessions (auth-sa.md §12). */
|
|
72
|
+
export function useSessions(): UseQueryResult<
|
|
73
|
+
readonly AuthSessionRecord[],
|
|
74
|
+
StapelApiError
|
|
75
|
+
> {
|
|
76
|
+
const api = useAuthApi();
|
|
77
|
+
return useQuery({
|
|
78
|
+
queryKey: authQueryKeys.sessions(),
|
|
79
|
+
queryFn: () => api.sessions(),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Registered passkeys (auth-sa.md §17). */
|
|
84
|
+
export function usePasskeys(): UseQueryResult<
|
|
85
|
+
readonly Passkey[],
|
|
86
|
+
StapelApiError
|
|
87
|
+
> {
|
|
88
|
+
const api = useAuthApi();
|
|
89
|
+
return useQuery({
|
|
90
|
+
queryKey: authQueryKeys.passkeys(),
|
|
91
|
+
queryFn: () => api.passkeys(),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A page of the security audit log (auth-sa.md §16). */
|
|
96
|
+
export function useAuditLog(
|
|
97
|
+
page = 1
|
|
98
|
+
): UseQueryResult<AuditPage, StapelApiError> {
|
|
99
|
+
const api = useAuthApi();
|
|
100
|
+
return useQuery({
|
|
101
|
+
queryKey: authQueryKeys.audit(page),
|
|
102
|
+
queryFn: () => api.auditLog(page),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Pending delayed authenticator change (auth-sa.md §9). */
|
|
107
|
+
export function useDelayedChangeStatus(
|
|
108
|
+
channel: OtpChannel
|
|
109
|
+
): UseQueryResult<DelayedChangeStatus, StapelApiError> {
|
|
110
|
+
const api = useAuthApi();
|
|
111
|
+
return useQuery({
|
|
112
|
+
queryKey: authQueryKeys.delayedChange(channel),
|
|
113
|
+
queryFn: () => api.changeDelayedStatus(channel),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* SSO domain lookup (auth-sa.md §18). Disabled until `domain` is a non-empty
|
|
119
|
+
* value — call after the user finishes typing their email.
|
|
120
|
+
*/
|
|
121
|
+
export function useSsoLookup(
|
|
122
|
+
domain: string
|
|
123
|
+
): UseQueryResult<SsoLookupResponse, StapelApiError> {
|
|
124
|
+
const api = useAuthApi();
|
|
125
|
+
return useQuery({
|
|
126
|
+
queryKey: authQueryKeys.ssoLookup(domain),
|
|
127
|
+
queryFn: () => api.ssoLookup(domain),
|
|
128
|
+
enabled: domain.length > 0,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Namespaced TanStack Query keys (frontend-standard §2 — "ключи неймспейснуты").
|
|
3
|
+
* Everything under the `"auth"` root so a host can invalidate the whole module
|
|
4
|
+
* or match a single resource. Persist scope is per-user via core's query
|
|
5
|
+
* runtime (`setPersistUser`). Explicit tuple return types satisfy
|
|
6
|
+
* `--isolatedDeclarations`.
|
|
7
|
+
*/
|
|
8
|
+
const ROOT = "auth" as const;
|
|
9
|
+
|
|
10
|
+
export const authQueryKeys: {
|
|
11
|
+
readonly all: readonly ["auth"];
|
|
12
|
+
capabilities(): readonly ["auth", "capabilities"];
|
|
13
|
+
me(): readonly ["auth", "me"];
|
|
14
|
+
securityStatus(): readonly ["auth", "security", "status"];
|
|
15
|
+
passwordMethods(): readonly ["auth", "password", "methods"];
|
|
16
|
+
sessions(): readonly ["auth", "sessions"];
|
|
17
|
+
passkeys(): readonly ["auth", "passkeys"];
|
|
18
|
+
audit(page: number): readonly ["auth", "audit", number];
|
|
19
|
+
delayedChange(channel: string): readonly ["auth", "change", "delayed", string];
|
|
20
|
+
ssoLookup(domain: string): readonly ["auth", "sso", "lookup", string];
|
|
21
|
+
} = {
|
|
22
|
+
all: [ROOT],
|
|
23
|
+
capabilities: () => [ROOT, "capabilities"],
|
|
24
|
+
me: () => [ROOT, "me"],
|
|
25
|
+
securityStatus: () => [ROOT, "security", "status"],
|
|
26
|
+
passwordMethods: () => [ROOT, "password", "methods"],
|
|
27
|
+
sessions: () => [ROOT, "sessions"],
|
|
28
|
+
passkeys: () => [ROOT, "passkeys"],
|
|
29
|
+
audit: (page) => [ROOT, "audit", page],
|
|
30
|
+
delayedChange: (channel) => [ROOT, "change", "delayed", channel],
|
|
31
|
+
ssoLookup: (domain) => [ROOT, "sso", "lookup", domain],
|
|
32
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { createStapelClient } from "@stapel/core";
|
|
2
|
+
import type { Analytics, PersistStorage, StapelClient } from "@stapel/core";
|
|
3
|
+
import { createAuthApi } from "../api/authApi.js";
|
|
4
|
+
import type { AuthApi } from "../api/authApi.js";
|
|
5
|
+
import {
|
|
6
|
+
createVerificationController,
|
|
7
|
+
} from "../flows/verificationFlow.js";
|
|
8
|
+
import type { VerificationController } from "../flows/verificationFlow.js";
|
|
9
|
+
import { createAuthSession } from "./session.js";
|
|
10
|
+
import type { AuthSession, TeardownReason } from "./session.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The wired auth runtime — the one place the flagship seams are connected
|
|
14
|
+
* (frontend-standard §2). It builds a {@link StapelClient} whose `getToken` /
|
|
15
|
+
* `onAuthRefresh` come from the {@link AuthSession} and whose
|
|
16
|
+
* `onVerificationChallenge` is the {@link VerificationController}'s handler, so
|
|
17
|
+
* the step-up factor flow and token rotation "just work" for every request.
|
|
18
|
+
*
|
|
19
|
+
* The returned `client` is what the host injects into core's
|
|
20
|
+
* `StapelConfigProvider` (as the default or the `"auth"` module client),
|
|
21
|
+
* preserving the client-injection fork seam of §7.2.
|
|
22
|
+
*/
|
|
23
|
+
export interface AuthRuntime {
|
|
24
|
+
readonly client: StapelClient;
|
|
25
|
+
readonly api: AuthApi;
|
|
26
|
+
readonly session: AuthSession;
|
|
27
|
+
readonly verification: VerificationController;
|
|
28
|
+
readonly analytics: Analytics | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CreateAuthRuntimeOptions {
|
|
32
|
+
/** e.g. `/auth/api` or `https://app.example.com/auth/api`. */
|
|
33
|
+
readonly baseUrl: string;
|
|
34
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
35
|
+
readonly storage?: PersistStorage;
|
|
36
|
+
readonly analytics?: Analytics | null;
|
|
37
|
+
/** Cookie mode (httponly JWT cookies) vs header/bearer. Default false. */
|
|
38
|
+
readonly cookieMode?: boolean;
|
|
39
|
+
/** Called after a session teardown (revoked/expired/logout). */
|
|
40
|
+
readonly onTeardown?: (reason: TeardownReason) => void;
|
|
41
|
+
/** Extra headers merged into every request (e.g. a captcha or tenant id). */
|
|
42
|
+
readonly defaultHeaders?: Record<string, string>;
|
|
43
|
+
/** THIN WebAuthn binding for the passkey verification factor. */
|
|
44
|
+
readonly webauthnGet?: (options: Record<string, unknown>) => Promise<unknown>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createAuthRuntime(
|
|
48
|
+
options: CreateAuthRuntimeOptions
|
|
49
|
+
): AuthRuntime {
|
|
50
|
+
const analytics = options.analytics ?? null;
|
|
51
|
+
|
|
52
|
+
// `api` is assigned after the client exists; session/verification reference
|
|
53
|
+
// it lazily through the holder, breaking the client↔session/verification
|
|
54
|
+
// wiring cycle without a reassigned `let`.
|
|
55
|
+
const holder: { current: AuthApi | null } = { current: null };
|
|
56
|
+
const getApi = (): AuthApi => {
|
|
57
|
+
if (holder.current === null) {
|
|
58
|
+
throw new Error("auth runtime used before initialization");
|
|
59
|
+
}
|
|
60
|
+
return holder.current;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const session = createAuthSession({
|
|
64
|
+
api: getApi,
|
|
65
|
+
...(options.storage !== undefined ? { storage: options.storage } : {}),
|
|
66
|
+
cookieMode: options.cookieMode ?? false,
|
|
67
|
+
...(options.onTeardown !== undefined ? { onTeardown: options.onTeardown } : {}),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const verification = createVerificationController({
|
|
71
|
+
api: getApi,
|
|
72
|
+
analytics,
|
|
73
|
+
...(options.webauthnGet !== undefined
|
|
74
|
+
? { webauthnGet: options.webauthnGet }
|
|
75
|
+
: {}),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const client = createStapelClient({
|
|
79
|
+
baseUrl: options.baseUrl,
|
|
80
|
+
...(options.fetch !== undefined ? { fetch: options.fetch } : {}),
|
|
81
|
+
getToken: () => session.getAccessToken(),
|
|
82
|
+
onAuthRefresh: () => session.onAuthRefresh(),
|
|
83
|
+
onVerificationChallenge: verification.handler,
|
|
84
|
+
...(options.defaultHeaders !== undefined
|
|
85
|
+
? { defaultHeaders: options.defaultHeaders }
|
|
86
|
+
: {}),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const api = createAuthApi(client);
|
|
90
|
+
holder.current = api;
|
|
91
|
+
|
|
92
|
+
return { client, api, session, verification, analytics };
|
|
93
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { StapelApiError } from "@stapel/core";
|
|
2
|
+
import type { PersistStorage } from "@stapel/core";
|
|
3
|
+
import type { AuthApi } from "../api/authApi.js";
|
|
4
|
+
import type { AuthResponse, AuthTokens, StapelUser } from "../api/types.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Why a session teardown fired (auth-sa.md §13, §19.3):
|
|
8
|
+
* - `revoked` — refresh token replayed/blacklisted (`error.401.refresh_revoked`);
|
|
9
|
+
* a stolen-token signal, hard logout.
|
|
10
|
+
* - `expired` — refresh failed for any other reason (TTL, network).
|
|
11
|
+
* - `logout` — explicit user logout.
|
|
12
|
+
*/
|
|
13
|
+
export type TeardownReason = "revoked" | "expired" | "logout";
|
|
14
|
+
|
|
15
|
+
export interface AuthSessionState {
|
|
16
|
+
readonly user: StapelUser | null;
|
|
17
|
+
readonly tokens: AuthTokens | null;
|
|
18
|
+
readonly status: "anonymous" | "authenticated";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface AuthSession {
|
|
22
|
+
getState(): AuthSessionState;
|
|
23
|
+
subscribe(listener: () => void): () => void;
|
|
24
|
+
/** For `createStapelClient({ getToken })`. Header mode only; cookie mode → null. */
|
|
25
|
+
getAccessToken(): string | null;
|
|
26
|
+
/** For `createStapelClient({ onAuthRefresh })`. Dedups + breaks recursion. */
|
|
27
|
+
onAuthRefresh(): Promise<string | null>;
|
|
28
|
+
/** Commit a session from any AuthResponse (login/register/merge/modify). */
|
|
29
|
+
adopt(response: AuthResponse): void;
|
|
30
|
+
/** Store a bare token pair (e.g. QR `login_request` fulfilment). */
|
|
31
|
+
setTokens(tokens: AuthTokens): void;
|
|
32
|
+
/** Explicit logout: revoke server-side, then tear down locally. */
|
|
33
|
+
logout(): Promise<void>;
|
|
34
|
+
/** Load a persisted session (call once on mount). */
|
|
35
|
+
restore(): Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface AuthSessionOptions {
|
|
39
|
+
/** Lazy to break the client↔session wiring cycle (see README). */
|
|
40
|
+
readonly api: AuthApi | (() => AuthApi);
|
|
41
|
+
/** Persist backend. Default: core's IndexedDB→localStorage→memory. */
|
|
42
|
+
readonly storage?: PersistStorage;
|
|
43
|
+
/** Persist key. Default `"stapel-auth:session"`. */
|
|
44
|
+
readonly persistKey?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Cookie mode: the backend sets httponly JWT cookies, so no bearer token is
|
|
47
|
+
* attached and refresh uses `GET /token/refresh/` (cookie). `getAccessToken`
|
|
48
|
+
* returns null. Default `false` (header/bearer mode).
|
|
49
|
+
*/
|
|
50
|
+
readonly cookieMode?: boolean;
|
|
51
|
+
/** Notified after a teardown so the host can purge caches / redirect. */
|
|
52
|
+
readonly onTeardown?: (reason: TeardownReason) => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const REFRESH_REVOKED = "error.401.refresh_revoked";
|
|
56
|
+
|
|
57
|
+
export function createAuthSession(options: AuthSessionOptions): AuthSession {
|
|
58
|
+
const persistKey = options.persistKey ?? "stapel-auth:session";
|
|
59
|
+
const cookieMode = options.cookieMode ?? false;
|
|
60
|
+
const resolveApi = (): AuthApi =>
|
|
61
|
+
typeof options.api === "function" ? options.api() : options.api;
|
|
62
|
+
|
|
63
|
+
let state: AuthSessionState = {
|
|
64
|
+
user: null,
|
|
65
|
+
tokens: null,
|
|
66
|
+
status: "anonymous",
|
|
67
|
+
};
|
|
68
|
+
const listeners = new Set<() => void>();
|
|
69
|
+
|
|
70
|
+
// Recursion guard: while the refresh network call is in flight, its own 401
|
|
71
|
+
// must NOT re-enter refresh (that call goes through the same client).
|
|
72
|
+
let refreshing = false;
|
|
73
|
+
let inFlight: Promise<string | null> | null = null;
|
|
74
|
+
|
|
75
|
+
function notify(): void {
|
|
76
|
+
for (const listener of listeners) listener();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function setState(next: AuthSessionState): void {
|
|
80
|
+
state = next;
|
|
81
|
+
notify();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function persist(): void {
|
|
85
|
+
// Only persist when a storage backend is configured; otherwise the session
|
|
86
|
+
// stays in memory for the page lifetime.
|
|
87
|
+
const storage = options.storage;
|
|
88
|
+
if (storage) {
|
|
89
|
+
void storage.set(persistKey, { user: state.user, tokens: state.tokens });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function adopt(response: AuthResponse): void {
|
|
94
|
+
setState({
|
|
95
|
+
user: response.user,
|
|
96
|
+
tokens: response.tokens,
|
|
97
|
+
status: "authenticated",
|
|
98
|
+
});
|
|
99
|
+
persist();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function setTokens(tokens: AuthTokens): void {
|
|
103
|
+
setState({ ...state, tokens, status: "authenticated" });
|
|
104
|
+
persist();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function clearLocal(): void {
|
|
108
|
+
setState({ user: null, tokens: null, status: "anonymous" });
|
|
109
|
+
const storage = options.storage;
|
|
110
|
+
if (storage) void storage.del(persistKey);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function teardown(reason: TeardownReason): void {
|
|
114
|
+
clearLocal();
|
|
115
|
+
options.onTeardown?.(reason);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function doRefresh(): Promise<string | null> {
|
|
119
|
+
const refreshToken = state.tokens?.refresh ?? null;
|
|
120
|
+
if (!cookieMode && refreshToken === null) {
|
|
121
|
+
teardown("expired");
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
refreshing = true;
|
|
125
|
+
try {
|
|
126
|
+
const r = await resolveApi().tokenRefresh(
|
|
127
|
+
cookieMode ? undefined : (refreshToken ?? undefined)
|
|
128
|
+
);
|
|
129
|
+
setTokens({ access: r.access, refresh: r.refresh });
|
|
130
|
+
return r.access;
|
|
131
|
+
} catch (error) {
|
|
132
|
+
const code = error instanceof StapelApiError ? error.code : "";
|
|
133
|
+
teardown(code === REFRESH_REVOKED ? "revoked" : "expired");
|
|
134
|
+
return null;
|
|
135
|
+
} finally {
|
|
136
|
+
refreshing = false;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function onAuthRefresh(): Promise<string | null> {
|
|
141
|
+
if (refreshing) return Promise.resolve(null);
|
|
142
|
+
if (inFlight) return inFlight;
|
|
143
|
+
inFlight = doRefresh().finally(() => {
|
|
144
|
+
inFlight = null;
|
|
145
|
+
});
|
|
146
|
+
return inFlight;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function logout(): Promise<void> {
|
|
150
|
+
try {
|
|
151
|
+
await resolveApi().logout();
|
|
152
|
+
} catch {
|
|
153
|
+
// Best-effort — tear down locally regardless.
|
|
154
|
+
}
|
|
155
|
+
teardown("logout");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function restore(): Promise<void> {
|
|
159
|
+
const storage = options.storage;
|
|
160
|
+
if (!storage) return;
|
|
161
|
+
const stored = (await storage.get(persistKey)) as
|
|
162
|
+
| { user: StapelUser | null; tokens: AuthTokens | null }
|
|
163
|
+
| undefined;
|
|
164
|
+
if (stored && (stored.tokens !== null || stored.user !== null)) {
|
|
165
|
+
setState({
|
|
166
|
+
user: stored.user,
|
|
167
|
+
tokens: stored.tokens,
|
|
168
|
+
status: stored.tokens !== null ? "authenticated" : "anonymous",
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
getState: () => state,
|
|
175
|
+
subscribe: (listener) => {
|
|
176
|
+
listeners.add(listener);
|
|
177
|
+
return () => {
|
|
178
|
+
listeners.delete(listener);
|
|
179
|
+
};
|
|
180
|
+
},
|
|
181
|
+
getAccessToken: () => (cookieMode ? null : (state.tokens?.access ?? null)),
|
|
182
|
+
onAuthRefresh,
|
|
183
|
+
adopt,
|
|
184
|
+
setTokens,
|
|
185
|
+
logout,
|
|
186
|
+
restore,
|
|
187
|
+
};
|
|
188
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/tsconfig",
|
|
3
|
+
"_comment": "Self-contained on purpose: standalone-buildable per frontend-standard §7. Mirrors the root tsconfig.base.json settings.",
|
|
4
|
+
"compilerOptions": {
|
|
5
|
+
"target": "ES2022",
|
|
6
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
7
|
+
"module": "ESNext",
|
|
8
|
+
"moduleResolution": "bundler",
|
|
9
|
+
"jsx": "react-jsx",
|
|
10
|
+
"strict": true,
|
|
11
|
+
"noUncheckedIndexedAccess": true,
|
|
12
|
+
"noImplicitOverride": true,
|
|
13
|
+
"exactOptionalPropertyTypes": true,
|
|
14
|
+
"isolatedModules": true,
|
|
15
|
+
"isolatedDeclarations": true,
|
|
16
|
+
"verbatimModuleSyntax": true,
|
|
17
|
+
"declaration": true,
|
|
18
|
+
"declarationMap": true,
|
|
19
|
+
"sourceMap": true,
|
|
20
|
+
"skipLibCheck": true,
|
|
21
|
+
"forceConsistentCasingInFileNames": true,
|
|
22
|
+
"outDir": "dist",
|
|
23
|
+
"rootDir": "src"
|
|
24
|
+
},
|
|
25
|
+
"include": ["src"]
|
|
26
|
+
}
|