@structured-id/ui-core 0.1.5

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 (156) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +47 -0
  3. package/dist/browser-vp/index.cjs +349 -0
  4. package/dist/browser-vp/index.d.cts +287 -0
  5. package/dist/browser-vp/index.d.ts +287 -0
  6. package/dist/browser-vp/index.js +349 -0
  7. package/dist/chunk-C3Q766CY.cjs +125 -0
  8. package/dist/chunk-J5YXRXB5.js +125 -0
  9. package/dist/index.cjs +19360 -0
  10. package/dist/index.d.cts +9536 -0
  11. package/dist/index.d.ts +9536 -0
  12. package/dist/index.js +19360 -0
  13. package/dist/storage-4CB2JTLW.cjs +22 -0
  14. package/dist/storage-THRWREZP.js +22 -0
  15. package/package.json +93 -0
  16. package/src/browser-vp/crypto.ts +254 -0
  17. package/src/browser-vp/index.test.ts +10 -0
  18. package/src/browser-vp/index.ts +118 -0
  19. package/src/browser-vp/prf.ts +181 -0
  20. package/src/browser-vp/storage.test.ts +9 -0
  21. package/src/browser-vp/storage.ts +165 -0
  22. package/src/browser-vp/types.ts +97 -0
  23. package/src/browser-vp/vp.test.ts +43 -0
  24. package/src/browser-vp/vp.ts +113 -0
  25. package/src/components/SidAvatar.vue +106 -0
  26. package/src/components/SidConfirmDialog.vue +125 -0
  27. package/src/components/SidStatusBadge.vue +71 -0
  28. package/src/components/index.ts +5 -0
  29. package/src/composables/index.ts +10 -0
  30. package/src/composables/useAuth.ts +40 -0
  31. package/src/composables/useBffAuth.ts +147 -0
  32. package/src/composables/useGrpc.test.ts +196 -0
  33. package/src/composables/useGrpc.ts +120 -0
  34. package/src/composables/useSession.ts +80 -0
  35. package/src/generated/google/api/http.ts +687 -0
  36. package/src/generated/google/protobuf/descriptor.ts +4865 -0
  37. package/src/generated/google/protobuf/duration.ts +230 -0
  38. package/src/generated/google/protobuf/empty.ts +94 -0
  39. package/src/generated/google/protobuf/struct.ts +484 -0
  40. package/src/generated/google/protobuf/timestamp.ts +287 -0
  41. package/src/generated/sid/v1/account/account.client.ts +527 -0
  42. package/src/generated/sid/v1/account/account.ts +3261 -0
  43. package/src/generated/sid/v1/admin/admin.client.ts +136 -0
  44. package/src/generated/sid/v1/admin/admin.ts +846 -0
  45. package/src/generated/sid/v1/admin/admin_roles.client.ts +181 -0
  46. package/src/generated/sid/v1/admin/admin_roles.ts +1606 -0
  47. package/src/generated/sid/v1/admin/analytics.client.ts +106 -0
  48. package/src/generated/sid/v1/admin/analytics.ts +1017 -0
  49. package/src/generated/sid/v1/admin/branding.client.ts +166 -0
  50. package/src/generated/sid/v1/admin/branding.ts +1906 -0
  51. package/src/generated/sid/v1/admin/enrollment.client.ts +165 -0
  52. package/src/generated/sid/v1/admin/enrollment.ts +1704 -0
  53. package/src/generated/sid/v1/admin/events.client.ts +195 -0
  54. package/src/generated/sid/v1/admin/events.ts +1900 -0
  55. package/src/generated/sid/v1/admin/flow.client.ts +183 -0
  56. package/src/generated/sid/v1/admin/flow.ts +1552 -0
  57. package/src/generated/sid/v1/admin/keys.client.ts +107 -0
  58. package/src/generated/sid/v1/admin/keys.ts +931 -0
  59. package/src/generated/sid/v1/admin/login_experience.client.ts +160 -0
  60. package/src/generated/sid/v1/admin/login_experience.ts +1061 -0
  61. package/src/generated/sid/v1/admin/migration.client.ts +127 -0
  62. package/src/generated/sid/v1/admin/migration.ts +1948 -0
  63. package/src/generated/sid/v1/admin/notifications.client.ts +128 -0
  64. package/src/generated/sid/v1/admin/notifications.ts +1332 -0
  65. package/src/generated/sid/v1/admin/operations.client.ts +241 -0
  66. package/src/generated/sid/v1/admin/operations.ts +2648 -0
  67. package/src/generated/sid/v1/admin/organizations.client.ts +57 -0
  68. package/src/generated/sid/v1/admin/organizations.ts +349 -0
  69. package/src/generated/sid/v1/admin/scopes.client.ts +171 -0
  70. package/src/generated/sid/v1/admin/scopes.ts +1324 -0
  71. package/src/generated/sid/v1/admin/security.client.ts +412 -0
  72. package/src/generated/sid/v1/admin/security.ts +4086 -0
  73. package/src/generated/sid/v1/admin/sessions.client.ts +114 -0
  74. package/src/generated/sid/v1/admin/sessions.ts +923 -0
  75. package/src/generated/sid/v1/admin/settings.client.ts +340 -0
  76. package/src/generated/sid/v1/admin/settings.ts +2395 -0
  77. package/src/generated/sid/v1/admin/user_profile.client.ts +57 -0
  78. package/src/generated/sid/v1/admin/user_profile.ts +730 -0
  79. package/src/generated/sid/v1/admin/webhooks.client.ts +269 -0
  80. package/src/generated/sid/v1/admin/webhooks.ts +2804 -0
  81. package/src/generated/sid/v1/attestation/attestation.client.ts +115 -0
  82. package/src/generated/sid/v1/attestation/attestation.ts +813 -0
  83. package/src/generated/sid/v1/authn/auth.client.ts +900 -0
  84. package/src/generated/sid/v1/authn/auth.ts +9287 -0
  85. package/src/generated/sid/v1/authn/flows.client.ts +413 -0
  86. package/src/generated/sid/v1/authn/flows.ts +3217 -0
  87. package/src/generated/sid/v1/authz/authz.client.ts +459 -0
  88. package/src/generated/sid/v1/authz/authz.ts +5343 -0
  89. package/src/generated/sid/v1/authz/governance.client.ts +102 -0
  90. package/src/generated/sid/v1/authz/governance.ts +818 -0
  91. package/src/generated/sid/v1/common/errors.ts +184 -0
  92. package/src/generated/sid/v1/common/pagination.ts +152 -0
  93. package/src/generated/sid/v1/common/transport.ts +227 -0
  94. package/src/generated/sid/v1/common/types.ts +151 -0
  95. package/src/generated/sid/v1/events/event_stream.client.ts +67 -0
  96. package/src/generated/sid/v1/events/event_stream.ts +374 -0
  97. package/src/generated/sid/v1/federation/upstream.client.ts +142 -0
  98. package/src/generated/sid/v1/federation/upstream.ts +1736 -0
  99. package/src/generated/sid/v1/identity/identity.client.ts +467 -0
  100. package/src/generated/sid/v1/identity/identity.ts +5613 -0
  101. package/src/generated/sid/v1/machine/machine_user.client.ts +219 -0
  102. package/src/generated/sid/v1/machine/machine_user.ts +2400 -0
  103. package/src/generated/sid/v1/machine/pat.client.ts +147 -0
  104. package/src/generated/sid/v1/machine/pat.ts +1268 -0
  105. package/src/generated/sid/v1/projects/projects.client.ts +286 -0
  106. package/src/generated/sid/v1/projects/projects.ts +2714 -0
  107. package/src/generated/sid/v1/scim/scim.client.ts +274 -0
  108. package/src/generated/sid/v1/scim/scim.ts +3043 -0
  109. package/src/generated/sid/v1/test/test.client.ts +232 -0
  110. package/src/generated/sid/v1/test/test.ts +1136 -0
  111. package/src/i18n/index.ts +40 -0
  112. package/src/index.ts +157 -0
  113. package/src/profile/auth.ts +15 -0
  114. package/src/profile/components/ConsentManager.vue +88 -0
  115. package/src/profile/components/ProfileEditor.vue +146 -0
  116. package/src/profile/components/SecuritySettings.vue +170 -0
  117. package/src/profile/components/SessionList.vue +100 -0
  118. package/src/profile/components/SidLoginForm.test.ts +80 -0
  119. package/src/profile/components/SidLoginForm.vue +230 -0
  120. package/src/profile/components/SidMfaChallenge.test.ts +127 -0
  121. package/src/profile/components/SidMfaChallenge.vue +243 -0
  122. package/src/profile/components/SidRegistrationForm.test.ts +79 -0
  123. package/src/profile/components/SidRegistrationForm.vue +317 -0
  124. package/src/profile/components/index.ts +11 -0
  125. package/src/profile/composables/index.ts +6 -0
  126. package/src/profile/composables/useAccountApi.ts +203 -0
  127. package/src/profile/composables/useAuthApi.opaque.test.ts +140 -0
  128. package/src/profile/composables/useAuthApi.ts +617 -0
  129. package/src/profile/composables/useIdentityApi.ts +123 -0
  130. package/src/profile/composables/useLoginSuccess.test.ts +112 -0
  131. package/src/profile/composables/useLoginSuccess.ts +65 -0
  132. package/src/profile/composables/useOpaqueAuth.test.ts +290 -0
  133. package/src/profile/composables/useOpaqueAuth.ts +191 -0
  134. package/src/profile/composables/useSecurityLevel.test.ts +198 -0
  135. package/src/profile/composables/useSecurityLevel.ts +324 -0
  136. package/src/profile/index.ts +18 -0
  137. package/src/quasar/SidPrincipalInput.vue +209 -0
  138. package/src/quasar/index.ts +8 -0
  139. package/src/stores/auth.test.ts +83 -0
  140. package/src/stores/auth.ts +92 -0
  141. package/src/stores/index.ts +1 -0
  142. package/src/transport/adaptive.test.ts +728 -0
  143. package/src/transport/adaptive.ts +308 -0
  144. package/src/transport/framing.test.ts +227 -0
  145. package/src/transport/framing.ts +98 -0
  146. package/src/transport/webtransport.test.ts +728 -0
  147. package/src/transport/webtransport.ts +323 -0
  148. package/src/types/auth.ts +58 -0
  149. package/src/types/index.ts +1 -0
  150. package/src/utils/formatters.test.ts +26 -0
  151. package/src/utils/formatters.ts +69 -0
  152. package/src/utils/index.ts +16 -0
  153. package/src/utils/normalizePrincipal.test.ts +188 -0
  154. package/src/utils/normalizePrincipal.ts +159 -0
  155. package/src/utils/validators.test.ts +75 -0
  156. package/src/utils/validators.ts +36 -0
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Profile identity API composable.
3
+ *
4
+ * Wraps IdentityServiceClient proto calls with Bearer auth.
5
+ * Shared between profile-ui-ce, profile-ui-ee, and frontend.
6
+ */
7
+ import {
8
+ getTransport,
9
+ IdentityServiceClient,
10
+ type Profile,
11
+ type Session,
12
+ type Credential,
13
+ type Principal,
14
+ ProfileStatus,
15
+ PrincipalType,
16
+ CredentialType,
17
+ AuthenticatorAttachment,
18
+ Timestamp,
19
+ } from "../../index";
20
+ import { authMeta } from "../auth";
21
+
22
+ function client(): IdentityServiceClient {
23
+ return new IdentityServiceClient(getTransport());
24
+ }
25
+
26
+ // ── Profile ──
27
+
28
+ export type { Profile, Principal };
29
+
30
+ export async function fetchCurrentProfile(): Promise<Profile> {
31
+ const { response } = await client().getCurrentProfile({}, { meta: authMeta() });
32
+ return response.profile!;
33
+ }
34
+
35
+ export async function updateProfile(
36
+ id: string,
37
+ update: {
38
+ givenName?: string;
39
+ familyName?: string;
40
+ middleName?: string;
41
+ avatarUrl?: string;
42
+ },
43
+ ): Promise<Profile> {
44
+ const { response } = await client().updateProfile(
45
+ {
46
+ id,
47
+ givenName: update.givenName,
48
+ familyName: update.familyName,
49
+ middleName: update.middleName,
50
+ avatarUrl: update.avatarUrl,
51
+ },
52
+ { meta: authMeta() },
53
+ );
54
+ return response.profile!;
55
+ }
56
+
57
+ // ── Sessions ──
58
+
59
+ export type { Session };
60
+
61
+ export async function fetchSessions(profileId: string): Promise<Session[]> {
62
+ const { response } = await client().listSessions(
63
+ { profileId },
64
+ { meta: authMeta() },
65
+ );
66
+ return response.sessions;
67
+ }
68
+
69
+ export async function revokeSession(sessionId: string): Promise<void> {
70
+ await client().revokeSession({ sessionId }, { meta: authMeta() });
71
+ }
72
+
73
+ // ── Credentials ──
74
+
75
+ export type { Credential };
76
+
77
+ export async function fetchCredentials(
78
+ profileId: string,
79
+ ): Promise<Credential[]> {
80
+ const { response } = await client().listCredentials(
81
+ { profileId },
82
+ { meta: authMeta() },
83
+ );
84
+ return response.credentials;
85
+ }
86
+
87
+ export async function revokeCredential(credentialId: string): Promise<void> {
88
+ await client().revokeCredential({ credentialId }, { meta: authMeta() });
89
+ }
90
+
91
+ export async function updateCredentialLabel(
92
+ credentialId: string,
93
+ label: string,
94
+ ): Promise<Credential> {
95
+ const { response } = await client().updateCredential(
96
+ { credentialId, label },
97
+ { meta: authMeta() },
98
+ );
99
+ return response.credential!;
100
+ }
101
+
102
+ // ── Principals ──
103
+
104
+ export async function fetchPrincipals(profileId: string): Promise<Principal[]> {
105
+ const { response } = await client().listPrincipals(
106
+ { profileId },
107
+ { meta: authMeta() },
108
+ );
109
+ return response.principals;
110
+ }
111
+
112
+ export async function removePrincipal(principalId: string): Promise<void> {
113
+ await client().removePrincipal({ principalId }, { meta: authMeta() });
114
+ }
115
+
116
+ // Re-export enums and Timestamp for convenience
117
+ export {
118
+ ProfileStatus,
119
+ PrincipalType,
120
+ CredentialType,
121
+ AuthenticatorAttachment,
122
+ Timestamp,
123
+ };
@@ -0,0 +1,112 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { setActivePinia, createPinia } from "pinia";
3
+ import { createApp, defineComponent } from "vue";
4
+ import { createRouter, createMemoryHistory } from "vue-router";
5
+ import { useLoginSuccess } from "./useLoginSuccess";
6
+ import { useAuthStore } from "../../stores/auth";
7
+
8
+ // Mock checkBffSession from BFF composable
9
+ vi.mock("../../composables/useBffAuth", () => ({
10
+ checkBffSession: vi.fn(),
11
+ }));
12
+
13
+ import { checkBffSession } from "../../composables/useBffAuth";
14
+ const mockCheckBffSession = vi.mocked(checkBffSession);
15
+
16
+ // Helper: run composable inside a real Vue app with router + pinia
17
+ async function withApp<T>(
18
+ fn: () => T,
19
+ ): Promise<{ result: T; router: ReturnType<typeof createRouter> }> {
20
+ const router = createRouter({
21
+ history: createMemoryHistory(),
22
+ routes: [
23
+ { path: "/", component: { template: "<div />" } },
24
+ { path: "/account", component: { template: "<div />" } },
25
+ { path: "/admin", component: { template: "<div />" } },
26
+ ],
27
+ });
28
+ await router.push("/");
29
+ await router.isReady();
30
+
31
+ const pinia = createPinia();
32
+ setActivePinia(pinia);
33
+
34
+ let result: T;
35
+ const App = defineComponent({
36
+ setup() {
37
+ result = fn();
38
+ return () => null;
39
+ },
40
+ });
41
+
42
+ const app = createApp(App);
43
+ app.use(pinia);
44
+ app.use(router);
45
+ app.mount(document.createElement("div"));
46
+
47
+ return { result: result!, router };
48
+ }
49
+
50
+ describe("useLoginSuccess (BFF cookie mode)", () => {
51
+ beforeEach(() => {
52
+ vi.clearAllMocks();
53
+ });
54
+
55
+ it("calls checkBffSession and populates auth store on success", async () => {
56
+ mockCheckBffSession.mockResolvedValue({
57
+ sub: "profile-uuid-1",
58
+ name: "Alice Smith",
59
+ email: "alice@example.com",
60
+ preferred_username: "alice",
61
+ });
62
+
63
+ const { result } = await withApp(() => useLoginSuccess("/account"));
64
+ const auth = useAuthStore();
65
+
66
+ await result.onLoginSuccess();
67
+
68
+ expect(mockCheckBffSession).toHaveBeenCalled();
69
+ expect(auth.isAuthenticated).toBe(true);
70
+ expect(auth.profileId).toBe("profile-uuid-1");
71
+ expect(auth.displayName).toBe("Alice Smith");
72
+ expect(auth.email).toBe("alice@example.com");
73
+ });
74
+
75
+ it("navigates to redirectTo after BFF session check", async () => {
76
+ mockCheckBffSession.mockResolvedValue({ sub: "p1" });
77
+
78
+ const { result, router } = await withApp(() => useLoginSuccess("/admin"));
79
+
80
+ await result.onLoginSuccess();
81
+
82
+ await router.isReady();
83
+ expect(router.currentRoute.value.path).toBe("/admin");
84
+ });
85
+
86
+ it("navigates even if BFF session check fails", async () => {
87
+ mockCheckBffSession.mockRejectedValue(new Error("Network error"));
88
+
89
+ const { result, router } = await withApp(() => useLoginSuccess("/account"));
90
+ const auth = useAuthStore();
91
+
92
+ await result.onLoginSuccess();
93
+
94
+ await router.isReady();
95
+ expect(router.currentRoute.value.path).toBe("/account");
96
+ // Auth state not set (session check failed)
97
+ expect(auth.isAuthenticated).toBe(false);
98
+ });
99
+
100
+ it("navigates when BFF session returns null (cookie not yet set)", async () => {
101
+ mockCheckBffSession.mockResolvedValue(null);
102
+
103
+ const { result, router } = await withApp(() => useLoginSuccess("/account"));
104
+ const auth = useAuthStore();
105
+
106
+ await result.onLoginSuccess();
107
+
108
+ await router.isReady();
109
+ expect(router.currentRoute.value.path).toBe("/account");
110
+ expect(auth.isAuthenticated).toBe(false);
111
+ });
112
+ });
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Shared post-login success handler.
3
+ *
4
+ * After BFF migration (#536i-3): tokens are set server-side via BFF session cookie.
5
+ * The SPA no longer stores tokens — it only calls checkBffSession() to populate
6
+ * the auth store from the BFF /auth/userinfo endpoint.
7
+ *
8
+ * The BFF login flow:
9
+ * 1. App redirects to /auth/login (PKCE initiation)
10
+ * 2. User authenticates on the IdP
11
+ * 3. BFF callback sets httpOnly session cookie
12
+ * 4. BFF redirects back to SPA
13
+ * 5. SPA calls checkBffSession() → setBffSession() → navigates to dashboard
14
+ *
15
+ * For apps where the SPA IS the auth UI (personal-ui OPAQUE flow):
16
+ * The OPAQUE login completes, sid-auth sets the server-side session,
17
+ * and this handler fetches userinfo from BFF to restore session state.
18
+ *
19
+ * Used by all apps (personal-ui, admin-ui, frontend) for consistent behavior.
20
+ */
21
+ import { useRouter } from "vue-router";
22
+ import { useAuthStore } from "../../index";
23
+ import { checkBffSession } from "../../index";
24
+
25
+ export interface LoginSuccessResult {
26
+ sessionId?: string;
27
+ }
28
+
29
+ /**
30
+ * Create a login success handler that restores BFF session state and navigates.
31
+ *
32
+ * @param redirectTo - Route to navigate after successful login (e.g., "/account", "/admin")
33
+ *
34
+ * @example
35
+ * ```vue
36
+ * <script setup>
37
+ * import { useLoginSuccess } from "@structured-id/ui-core/profile";
38
+ * const { onLoginSuccess } = useLoginSuccess("/account");
39
+ * </script>
40
+ * <template>
41
+ * <SidLoginForm :login-fn="login" @success="onLoginSuccess" />
42
+ * </template>
43
+ * ```
44
+ */
45
+ export function useLoginSuccess(redirectTo: string) {
46
+ const auth = useAuthStore();
47
+ const router = useRouter();
48
+
49
+ async function onLoginSuccess(_result?: LoginSuccessResult) {
50
+ // Restore BFF session from /auth/userinfo — the BFF set the httpOnly cookie
51
+ // during the login flow. We fetch userinfo to populate the auth store.
52
+ try {
53
+ const userInfo = await checkBffSession();
54
+ if (userInfo) {
55
+ auth.setBffSession(userInfo);
56
+ }
57
+ } catch {
58
+ // Non-critical — navigation will succeed, route guard will re-check session
59
+ }
60
+
61
+ await router.push(redirectTo);
62
+ }
63
+
64
+ return { onLoginSuccess };
65
+ }
@@ -0,0 +1,290 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import {
3
+ createOpaqueAuth,
4
+ type OpaqueClientApi,
5
+ type OpaqueClientFactory,
6
+ } from "./useOpaqueAuth";
7
+
8
+ // Mock the gRPC wrappers
9
+ vi.mock("./useAuthApi", () => ({
10
+ opaqueLoginStart: vi.fn(),
11
+ opaqueLoginFinish: vi.fn(),
12
+ opaqueRegistrationStart: vi.fn(),
13
+ opaqueRegistrationFinish: vi.fn(),
14
+ }));
15
+
16
+ import {
17
+ opaqueLoginStart,
18
+ opaqueLoginFinish,
19
+ opaqueRegistrationStart,
20
+ opaqueRegistrationFinish,
21
+ type OpaqueLoginStartResponse,
22
+ type OpaqueLoginFinishResponse,
23
+ type OpaqueRegistrationStartResponse,
24
+ type OpaqueRegistrationFinishResponse,
25
+ } from "./useAuthApi";
26
+
27
+ const mockLoginStart = vi.mocked(opaqueLoginStart);
28
+ const mockLoginFinish = vi.mocked(opaqueLoginFinish);
29
+ const mockRegStart = vi.mocked(opaqueRegistrationStart);
30
+ const mockRegFinish = vi.mocked(opaqueRegistrationFinish);
31
+
32
+ // ── Fake OpaqueClient ──
33
+
34
+ function createFakeOpaqueClient(): OpaqueClientApi {
35
+ return {
36
+ loginStart: vi.fn().mockResolvedValue({
37
+ ke1: new Uint8Array([1, 2, 3]),
38
+ state: "login-state-opaque",
39
+ }),
40
+ loginFinish: vi.fn().mockResolvedValue({
41
+ ke3: new Uint8Array([7, 8, 9]),
42
+ }),
43
+ registrationStart: vi.fn().mockResolvedValue({
44
+ request: new Uint8Array([10, 11, 12]),
45
+ state: "reg-state-opaque",
46
+ }),
47
+ registrationFinish: vi.fn().mockResolvedValue({
48
+ record: new Uint8Array([20, 21, 22]),
49
+ }),
50
+ };
51
+ }
52
+
53
+ let fakeClient: OpaqueClientApi;
54
+ let factory: OpaqueClientFactory;
55
+
56
+ describe("createOpaqueAuth", () => {
57
+ beforeEach(() => {
58
+ vi.clearAllMocks();
59
+ fakeClient = createFakeOpaqueClient();
60
+ factory = vi.fn().mockReturnValue(fakeClient);
61
+ });
62
+
63
+ describe("login", () => {
64
+ beforeEach(() => {
65
+ mockLoginStart.mockResolvedValue({
66
+ credentialResponse: new Uint8Array([4, 5, 6]),
67
+ serverLoginState: "server-state-123",
68
+ } as unknown as OpaqueLoginStartResponse);
69
+ mockLoginFinish.mockResolvedValue({
70
+ sessionId: "sess-1",
71
+ accessToken: "jwt-token",
72
+ expiresIn: BigInt(3600),
73
+ } as unknown as OpaqueLoginFinishResponse);
74
+ });
75
+
76
+ it("performs full 4-step OPAQUE login flow", async () => {
77
+ const { login } = createOpaqueAuth(factory, "test.server");
78
+ const result = await login("alice@test.com", "password123");
79
+
80
+ // Factory creates client with correct serverId and clientId
81
+ expect(factory).toHaveBeenCalledWith("test.server", "alice@test.com");
82
+
83
+ // Step 1: client-side crypto
84
+ expect(fakeClient.loginStart).toHaveBeenCalledWith("password123");
85
+
86
+ // Step 2: gRPC call with ke1
87
+ expect(mockLoginStart).toHaveBeenCalledWith(
88
+ "alice@test.com",
89
+ new Uint8Array([1, 2, 3]),
90
+ );
91
+
92
+ // Step 3: client-side finalization
93
+ expect(fakeClient.loginFinish).toHaveBeenCalledWith(
94
+ "password123",
95
+ new Uint8Array([4, 5, 6]),
96
+ "login-state-opaque",
97
+ );
98
+
99
+ // Step 4: gRPC finish call
100
+ expect(mockLoginFinish).toHaveBeenCalledWith(
101
+ "alice@test.com",
102
+ new Uint8Array([7, 8, 9]),
103
+ "server-state-123",
104
+ );
105
+
106
+ // Result: bigint expiresIn converted to number
107
+ expect(result).toEqual({
108
+ sessionId: "sess-1",
109
+ accessToken: "jwt-token",
110
+ expiresIn: 3600,
111
+ });
112
+ });
113
+
114
+ it("calls onStep callback at each phase", async () => {
115
+ const { login } = createOpaqueAuth(factory);
116
+ const onStep = vi.fn();
117
+ await login("alice@test.com", "pass", onStep);
118
+
119
+ expect(onStep).toHaveBeenCalledWith(1);
120
+ expect(onStep).toHaveBeenCalledWith(2);
121
+ expect(onStep).toHaveBeenCalledWith(3);
122
+ expect(onStep).toHaveBeenCalledTimes(3);
123
+ });
124
+
125
+ it("propagates gRPC error from loginStart", async () => {
126
+ mockLoginStart.mockRejectedValue(new Error("UNAUTHENTICATED"));
127
+
128
+ const { login } = createOpaqueAuth(factory);
129
+ await expect(login("alice@test.com", "wrong")).rejects.toThrow(
130
+ "UNAUTHENTICATED",
131
+ );
132
+ });
133
+
134
+ it("propagates gRPC error from loginFinish", async () => {
135
+ mockLoginFinish.mockRejectedValue(new Error("INTERNAL"));
136
+
137
+ const { login } = createOpaqueAuth(factory);
138
+ await expect(login("alice@test.com", "pass")).rejects.toThrow("INTERNAL");
139
+ });
140
+
141
+ it("propagates client-side crypto error", async () => {
142
+ (fakeClient.loginStart as ReturnType<typeof vi.fn>).mockRejectedValue(
143
+ new Error("WASM init failed"),
144
+ );
145
+
146
+ const { login } = createOpaqueAuth(factory);
147
+ await expect(login("alice@test.com", "pass")).rejects.toThrow(
148
+ "WASM init failed",
149
+ );
150
+ });
151
+
152
+ it("uses default serverId when not specified", async () => {
153
+ const { login } = createOpaqueAuth(factory);
154
+ await login("bob@test.com", "pass");
155
+
156
+ expect(factory).toHaveBeenCalledWith("structured.id", "bob@test.com");
157
+ });
158
+ });
159
+
160
+ describe("register", () => {
161
+ beforeEach(() => {
162
+ mockRegStart.mockResolvedValue({
163
+ registrationResponse: new Uint8Array([13, 14, 15]),
164
+ serverSetup: "setup-xyz",
165
+ } as unknown as OpaqueRegistrationStartResponse);
166
+ mockRegFinish.mockResolvedValue({
167
+ profileId: "profile-uuid",
168
+ credentialId: "cred-uuid",
169
+ serverPepper: new Uint8Array(),
170
+ } as unknown as OpaqueRegistrationFinishResponse);
171
+ });
172
+
173
+ it("performs full 4-step OPAQUE registration flow", async () => {
174
+ const { register } = createOpaqueAuth(factory, "test.server");
175
+ const result = await register("bob@test.com", "newpass");
176
+
177
+ expect(factory).toHaveBeenCalledWith("test.server", "bob@test.com");
178
+
179
+ // Step 1: client-side request
180
+ expect(fakeClient.registrationStart).toHaveBeenCalledWith("newpass");
181
+
182
+ // Step 2: gRPC start
183
+ expect(mockRegStart).toHaveBeenCalledWith(
184
+ "bob@test.com",
185
+ new Uint8Array([10, 11, 12]),
186
+ );
187
+
188
+ // Step 3: client-side record generation
189
+ expect(fakeClient.registrationFinish).toHaveBeenCalledWith(
190
+ "newpass",
191
+ new Uint8Array([13, 14, 15]),
192
+ "reg-state-opaque",
193
+ );
194
+
195
+ // Step 4: gRPC finish
196
+ expect(mockRegFinish).toHaveBeenCalledWith(
197
+ "bob@test.com",
198
+ new Uint8Array([20, 21, 22]),
199
+ "setup-xyz",
200
+ );
201
+
202
+ expect(result).toEqual({
203
+ profileId: "profile-uuid",
204
+ credentialId: "cred-uuid",
205
+ });
206
+ });
207
+
208
+ it("propagates gRPC error from registrationStart", async () => {
209
+ mockRegStart.mockRejectedValue(new Error("ALREADY_EXISTS"));
210
+
211
+ const { register } = createOpaqueAuth(factory);
212
+ await expect(register("taken@test.com", "pass")).rejects.toThrow(
213
+ "ALREADY_EXISTS",
214
+ );
215
+ });
216
+
217
+ it("propagates gRPC error from registrationFinish", async () => {
218
+ mockRegFinish.mockRejectedValue(new Error("INTERNAL"));
219
+
220
+ const { register } = createOpaqueAuth(factory);
221
+ await expect(register("bob@test.com", "pass")).rejects.toThrow(
222
+ "INTERNAL",
223
+ );
224
+ });
225
+ });
226
+
227
+ describe("computeRegistrationRecord", () => {
228
+ beforeEach(() => {
229
+ mockRegStart.mockResolvedValue({
230
+ registrationResponse: new Uint8Array([13, 14, 15]),
231
+ serverSetup: "setup-xyz",
232
+ } as unknown as OpaqueRegistrationStartResponse);
233
+ });
234
+
235
+ it("returns registration record without calling registrationFinish RPC", async () => {
236
+ const { computeRegistrationRecord } = createOpaqueAuth(
237
+ factory,
238
+ "test.server",
239
+ );
240
+ const record = await computeRegistrationRecord(
241
+ "alice@test.com",
242
+ "newpass",
243
+ );
244
+
245
+ expect(factory).toHaveBeenCalledWith("test.server", "alice@test.com");
246
+
247
+ // Step 1: client-side request
248
+ expect(fakeClient.registrationStart).toHaveBeenCalledWith("newpass");
249
+
250
+ // Step 2: gRPC start (server generates RegistrationResponse)
251
+ expect(mockRegStart).toHaveBeenCalledWith(
252
+ "alice@test.com",
253
+ new Uint8Array([10, 11, 12]),
254
+ );
255
+
256
+ // Step 3: client-side record generation
257
+ expect(fakeClient.registrationFinish).toHaveBeenCalledWith(
258
+ "newpass",
259
+ new Uint8Array([13, 14, 15]),
260
+ "reg-state-opaque",
261
+ );
262
+
263
+ // registrationFinish RPC must NOT be called
264
+ expect(mockRegFinish).not.toHaveBeenCalled();
265
+
266
+ // Returns raw record bytes
267
+ expect(record).toEqual(new Uint8Array([20, 21, 22]));
268
+ });
269
+
270
+ it("propagates gRPC error from registrationStart", async () => {
271
+ mockRegStart.mockRejectedValue(new Error("NOT_FOUND"));
272
+
273
+ const { computeRegistrationRecord } = createOpaqueAuth(factory);
274
+ await expect(
275
+ computeRegistrationRecord("unknown@test.com", "pass"),
276
+ ).rejects.toThrow("NOT_FOUND");
277
+ });
278
+
279
+ it("propagates client-side crypto error", async () => {
280
+ (
281
+ fakeClient.registrationFinish as ReturnType<typeof vi.fn>
282
+ ).mockRejectedValue(new Error("WASM crypto error"));
283
+
284
+ const { computeRegistrationRecord } = createOpaqueAuth(factory);
285
+ await expect(
286
+ computeRegistrationRecord("alice@test.com", "pass"),
287
+ ).rejects.toThrow("WASM crypto error");
288
+ });
289
+ });
290
+ });