@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.
Files changed (191) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/LICENSE +21 -0
  3. package/MODULE.md +147 -0
  4. package/README.md +116 -0
  5. package/dist/api/authApi.d.ts +68 -0
  6. package/dist/api/authApi.d.ts.map +1 -0
  7. package/dist/api/authApi.js +90 -0
  8. package/dist/api/authApi.js.map +1 -0
  9. package/dist/api/types.d.ts +238 -0
  10. package/dist/api/types.d.ts.map +1 -0
  11. package/dist/api/types.js +14 -0
  12. package/dist/api/types.js.map +1 -0
  13. package/dist/api/urls.d.ts +41 -0
  14. package/dist/api/urls.d.ts.map +1 -0
  15. package/dist/api/urls.js +86 -0
  16. package/dist/api/urls.js.map +1 -0
  17. package/dist/flows/anonymousFlow.d.ts +33 -0
  18. package/dist/flows/anonymousFlow.d.ts.map +1 -0
  19. package/dist/flows/anonymousFlow.js +26 -0
  20. package/dist/flows/anonymousFlow.js.map +1 -0
  21. package/dist/flows/authenticatorChangeFlow.d.ts +67 -0
  22. package/dist/flows/authenticatorChangeFlow.d.ts.map +1 -0
  23. package/dist/flows/authenticatorChangeFlow.js +79 -0
  24. package/dist/flows/authenticatorChangeFlow.js.map +1 -0
  25. package/dist/flows/createFlowMachine.d.ts +55 -0
  26. package/dist/flows/createFlowMachine.d.ts.map +1 -0
  27. package/dist/flows/createFlowMachine.js +56 -0
  28. package/dist/flows/createFlowMachine.js.map +1 -0
  29. package/dist/flows/errors.d.ts +15 -0
  30. package/dist/flows/errors.d.ts.map +1 -0
  31. package/dist/flows/errors.js +17 -0
  32. package/dist/flows/errors.js.map +1 -0
  33. package/dist/flows/magicLinkFlow.d.ts +41 -0
  34. package/dist/flows/magicLinkFlow.d.ts.map +1 -0
  35. package/dist/flows/magicLinkFlow.js +29 -0
  36. package/dist/flows/magicLinkFlow.js.map +1 -0
  37. package/dist/flows/oauthFlow.d.ts +58 -0
  38. package/dist/flows/oauthFlow.d.ts.map +1 -0
  39. package/dist/flows/oauthFlow.js +53 -0
  40. package/dist/flows/oauthFlow.js.map +1 -0
  41. package/dist/flows/otpFlow.d.ts +74 -0
  42. package/dist/flows/otpFlow.d.ts.map +1 -0
  43. package/dist/flows/otpFlow.js +68 -0
  44. package/dist/flows/otpFlow.js.map +1 -0
  45. package/dist/flows/passkeyFlow.d.ts +75 -0
  46. package/dist/flows/passkeyFlow.d.ts.map +1 -0
  47. package/dist/flows/passkeyFlow.js +100 -0
  48. package/dist/flows/passkeyFlow.js.map +1 -0
  49. package/dist/flows/passwordChangeFlow.d.ts +53 -0
  50. package/dist/flows/passwordChangeFlow.d.ts.map +1 -0
  51. package/dist/flows/passwordChangeFlow.js +51 -0
  52. package/dist/flows/passwordChangeFlow.js.map +1 -0
  53. package/dist/flows/passwordLoginFlow.d.ts +62 -0
  54. package/dist/flows/passwordLoginFlow.d.ts.map +1 -0
  55. package/dist/flows/passwordLoginFlow.js +55 -0
  56. package/dist/flows/passwordLoginFlow.js.map +1 -0
  57. package/dist/flows/passwordResetFlow.d.ts +56 -0
  58. package/dist/flows/passwordResetFlow.d.ts.map +1 -0
  59. package/dist/flows/passwordResetFlow.js +57 -0
  60. package/dist/flows/passwordResetFlow.js.map +1 -0
  61. package/dist/flows/qrLoginFlow.d.ts +55 -0
  62. package/dist/flows/qrLoginFlow.d.ts.map +1 -0
  63. package/dist/flows/qrLoginFlow.js +91 -0
  64. package/dist/flows/qrLoginFlow.js.map +1 -0
  65. package/dist/flows/ssoFlow.d.ts +46 -0
  66. package/dist/flows/ssoFlow.d.ts.map +1 -0
  67. package/dist/flows/ssoFlow.js +34 -0
  68. package/dist/flows/ssoFlow.js.map +1 -0
  69. package/dist/flows/totpSetupFlow.d.ts +49 -0
  70. package/dist/flows/totpSetupFlow.d.ts.map +1 -0
  71. package/dist/flows/totpSetupFlow.js +47 -0
  72. package/dist/flows/totpSetupFlow.js.map +1 -0
  73. package/dist/flows/useFlow.d.ts +9 -0
  74. package/dist/flows/useFlow.d.ts.map +1 -0
  75. package/dist/flows/useFlow.js +11 -0
  76. package/dist/flows/useFlow.js.map +1 -0
  77. package/dist/flows/verificationFlow.d.ts +108 -0
  78. package/dist/flows/verificationFlow.d.ts.map +1 -0
  79. package/dist/flows/verificationFlow.js +195 -0
  80. package/dist/flows/verificationFlow.js.map +1 -0
  81. package/dist/headless/AuthProvider.d.ts +18 -0
  82. package/dist/headless/AuthProvider.d.ts.map +1 -0
  83. package/dist/headless/AuthProvider.js +22 -0
  84. package/dist/headless/AuthProvider.js.map +1 -0
  85. package/dist/headless/Passkey.d.ts +31 -0
  86. package/dist/headless/Passkey.d.ts.map +1 -0
  87. package/dist/headless/Passkey.js +51 -0
  88. package/dist/headless/Passkey.js.map +1 -0
  89. package/dist/headless/PasswordChange.d.ts +20 -0
  90. package/dist/headless/PasswordChange.d.ts.map +1 -0
  91. package/dist/headless/PasswordChange.js +30 -0
  92. package/dist/headless/PasswordChange.js.map +1 -0
  93. package/dist/headless/PasswordLogin.d.ts +17 -0
  94. package/dist/headless/PasswordLogin.d.ts.map +1 -0
  95. package/dist/headless/PasswordLogin.js +31 -0
  96. package/dist/headless/PasswordLogin.js.map +1 -0
  97. package/dist/headless/PasswordReset.d.ts +19 -0
  98. package/dist/headless/PasswordReset.d.ts.map +1 -0
  99. package/dist/headless/PasswordReset.js +34 -0
  100. package/dist/headless/PasswordReset.js.map +1 -0
  101. package/dist/headless/PasswordlessLogin.d.ts +28 -0
  102. package/dist/headless/PasswordlessLogin.d.ts.map +1 -0
  103. package/dist/headless/PasswordlessLogin.js +42 -0
  104. package/dist/headless/PasswordlessLogin.js.map +1 -0
  105. package/dist/headless/QrLogin.d.ts +19 -0
  106. package/dist/headless/QrLogin.d.ts.map +1 -0
  107. package/dist/headless/QrLogin.js +32 -0
  108. package/dist/headless/QrLogin.js.map +1 -0
  109. package/dist/headless/TotpSetup.d.ts +17 -0
  110. package/dist/headless/TotpSetup.d.ts.map +1 -0
  111. package/dist/headless/TotpSetup.js +26 -0
  112. package/dist/headless/TotpSetup.js.map +1 -0
  113. package/dist/headless/VerificationChallenge.d.ts +37 -0
  114. package/dist/headless/VerificationChallenge.d.ts.map +1 -0
  115. package/dist/headless/VerificationChallenge.js +40 -0
  116. package/dist/headless/VerificationChallenge.js.map +1 -0
  117. package/dist/headless/misc.d.ts +47 -0
  118. package/dist/headless/misc.d.ts.map +1 -0
  119. package/dist/headless/misc.js +84 -0
  120. package/dist/headless/misc.js.map +1 -0
  121. package/dist/i18n/keys.d.ts +34 -0
  122. package/dist/i18n/keys.d.ts.map +1 -0
  123. package/dist/i18n/keys.js +83 -0
  124. package/dist/i18n/keys.js.map +1 -0
  125. package/dist/index.d.ts +73 -0
  126. package/dist/index.d.ts.map +1 -0
  127. package/dist/index.js +48 -0
  128. package/dist/index.js.map +1 -0
  129. package/dist/model/context.d.ts +22 -0
  130. package/dist/model/context.d.ts.map +1 -0
  131. package/dist/model/context.js +34 -0
  132. package/dist/model/context.js.map +1 -0
  133. package/dist/model/mutations.d.ts +28 -0
  134. package/dist/model/mutations.d.ts.map +1 -0
  135. package/dist/model/mutations.js +108 -0
  136. package/dist/model/mutations.js.map +1 -0
  137. package/dist/model/queries.d.ts +30 -0
  138. package/dist/model/queries.d.ts.map +1 -0
  139. package/dist/model/queries.js +87 -0
  140. package/dist/model/queries.js.map +1 -0
  141. package/dist/model/queryKeys.d.ts +13 -0
  142. package/dist/model/queryKeys.d.ts.map +1 -0
  143. package/dist/model/queryKeys.js +21 -0
  144. package/dist/model/queryKeys.js.map +1 -0
  145. package/dist/model/runtime.d.ts +39 -0
  146. package/dist/model/runtime.d.ts.map +1 -0
  147. package/dist/model/runtime.js +44 -0
  148. package/dist/model/runtime.js.map +1 -0
  149. package/dist/model/session.d.ts +50 -0
  150. package/dist/model/session.d.ts.map +1 -0
  151. package/dist/model/session.js +124 -0
  152. package/dist/model/session.js.map +1 -0
  153. package/package.json +68 -0
  154. package/src/api/authApi.ts +332 -0
  155. package/src/api/types.ts +291 -0
  156. package/src/api/urls.ts +99 -0
  157. package/src/flows/anonymousFlow.ts +57 -0
  158. package/src/flows/authenticatorChangeFlow.ts +160 -0
  159. package/src/flows/createFlowMachine.ts +126 -0
  160. package/src/flows/errors.ts +29 -0
  161. package/src/flows/magicLinkFlow.ts +68 -0
  162. package/src/flows/oauthFlow.ts +114 -0
  163. package/src/flows/otpFlow.ts +156 -0
  164. package/src/flows/passkeyFlow.ts +191 -0
  165. package/src/flows/passwordChangeFlow.ts +114 -0
  166. package/src/flows/passwordLoginFlow.ts +122 -0
  167. package/src/flows/passwordResetFlow.ts +123 -0
  168. package/src/flows/qrLoginFlow.ts +158 -0
  169. package/src/flows/ssoFlow.ts +84 -0
  170. package/src/flows/totpSetupFlow.ts +96 -0
  171. package/src/flows/useFlow.ts +16 -0
  172. package/src/flows/verificationFlow.ts +341 -0
  173. package/src/headless/AuthProvider.tsx +30 -0
  174. package/src/headless/Passkey.tsx +97 -0
  175. package/src/headless/PasswordChange.tsx +46 -0
  176. package/src/headless/PasswordLogin.tsx +49 -0
  177. package/src/headless/PasswordReset.tsx +51 -0
  178. package/src/headless/PasswordlessLogin.tsx +60 -0
  179. package/src/headless/QrLogin.tsx +52 -0
  180. package/src/headless/TotpSetup.tsx +40 -0
  181. package/src/headless/VerificationChallenge.tsx +54 -0
  182. package/src/headless/misc.tsx +151 -0
  183. package/src/i18n/keys.ts +94 -0
  184. package/src/index.ts +229 -0
  185. package/src/model/context.tsx +51 -0
  186. package/src/model/mutations.ts +152 -0
  187. package/src/model/queries.ts +130 -0
  188. package/src/model/queryKeys.ts +32 -0
  189. package/src/model/runtime.ts +93 -0
  190. package/src/model/session.ts +188 -0
  191. 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
+ }