@structured-id/ui-core 1.1.0 → 1.3.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 (101) hide show
  1. package/README.md +9 -5
  2. package/dist/browser-vp/index.d.cts +0 -11
  3. package/dist/browser-vp/index.d.ts +0 -11
  4. package/dist/index.cjs +13721 -9514
  5. package/dist/index.d.cts +4362 -2375
  6. package/dist/index.d.ts +4362 -2375
  7. package/dist/index.js +13937 -9730
  8. package/package.json +3 -3
  9. package/src/browser-vp/crypto.ts +0 -1
  10. package/src/browser-vp/index.ts +0 -2
  11. package/src/browser-vp/prf.ts +0 -2
  12. package/src/browser-vp/storage.ts +0 -2
  13. package/src/browser-vp/types.ts +0 -2
  14. package/src/browser-vp/vp.ts +0 -2
  15. package/src/composables/index.ts +6 -1
  16. package/src/composables/useAccountTransport.test.ts +121 -0
  17. package/src/composables/useAccountTransport.ts +69 -0
  18. package/src/composables/useBffAuth.ts +44 -34
  19. package/src/generated/google/api/httpbody.ts +159 -0
  20. package/src/generated/google/protobuf/any.ts +326 -0
  21. package/src/generated/google/rpc/error_details.ts +1386 -0
  22. package/src/generated/google/rpc/status.ts +127 -0
  23. package/src/generated/sid/v1/account/account.ts +1 -1
  24. package/src/generated/sid/v1/admin/admin.client.ts +15 -12
  25. package/src/generated/sid/v1/admin/admin.ts +24 -89
  26. package/src/generated/sid/v1/admin/enrollment.client.ts +24 -12
  27. package/src/generated/sid/v1/admin/enrollment.ts +65 -3
  28. package/src/generated/sid/v1/admin/events.client.ts +8 -8
  29. package/src/generated/sid/v1/admin/flow.ts +1 -1
  30. package/src/generated/sid/v1/admin/keys.ts +1 -1
  31. package/src/generated/sid/v1/admin/notifications.client.ts +12 -6
  32. package/src/generated/sid/v1/admin/notifications.ts +11 -70
  33. package/src/generated/sid/v1/admin/operations.ts +20 -35
  34. package/src/generated/sid/v1/admin/organizations.client.ts +2 -2
  35. package/src/generated/sid/v1/admin/organizations.ts +15 -32
  36. package/src/generated/sid/v1/admin/provisioning.client.ts +232 -0
  37. package/src/generated/sid/v1/admin/provisioning.ts +1383 -0
  38. package/src/generated/sid/v1/admin/scopes.ts +1 -1
  39. package/src/generated/sid/v1/admin/security.client.ts +22 -24
  40. package/src/generated/sid/v1/admin/security.ts +13 -22
  41. package/src/generated/sid/v1/admin/settings.client.ts +29 -18
  42. package/src/generated/sid/v1/admin/settings.ts +2 -82
  43. package/src/generated/sid/v1/attestation/attestation.client.ts +6 -6
  44. package/src/generated/sid/v1/attestation/attestation.ts +8 -8
  45. package/src/generated/sid/v1/authn/auth.client.ts +101 -58
  46. package/src/generated/sid/v1/authn/auth.ts +842 -414
  47. package/src/generated/sid/v1/authn/flows.ts +0 -12
  48. package/src/generated/sid/v1/authn/issuer.client.ts +489 -0
  49. package/src/generated/sid/v1/authn/issuer.ts +1600 -0
  50. package/src/generated/sid/v1/authn/password_history.client.ts +57 -0
  51. package/src/generated/sid/v1/authn/password_history.ts +578 -0
  52. package/src/generated/sid/v1/authn/system_integration.client.ts +73 -0
  53. package/src/generated/sid/v1/authn/system_integration.ts +218 -0
  54. package/src/generated/sid/v1/authz/authz.ts +1498 -317
  55. package/src/generated/sid/v1/authz/forward_auth.client.ts +97 -0
  56. package/src/generated/sid/v1/authz/forward_auth.ts +79 -0
  57. package/src/generated/sid/v1/authz/governance.client.ts +2 -2
  58. package/src/generated/sid/v1/authz/governance.ts +61 -4
  59. package/src/generated/sid/v1/common/errors.ts +532 -6
  60. package/src/generated/sid/v1/events/event_stream.client.ts +2 -2
  61. package/src/generated/sid/v1/events/event_stream.ts +2 -1
  62. package/src/generated/sid/v1/identity/identity.ts +98 -58
  63. package/src/generated/sid/v1/ids/ids.ts +395 -0
  64. package/src/generated/sid/v1/machine/machine_user.ts +0 -36
  65. package/src/generated/sid/v1/machine/pat.client.ts +2 -19
  66. package/src/generated/sid/v1/machine/pat.ts +1 -186
  67. package/src/generated/sid/v1/projects/projects.client.ts +137 -19
  68. package/src/generated/sid/v1/projects/projects.ts +2105 -405
  69. package/src/generated/sid/v1/scim/scim.client.ts +248 -2
  70. package/src/generated/sid/v1/scim/scim.ts +153 -14
  71. package/src/generated/sid/v1/test/test.ts +1 -1
  72. package/src/i18n/index.ts +0 -11
  73. package/src/index.ts +36 -2
  74. package/src/package-exports.test.ts +18 -0
  75. package/src/profile/components/SidLoginForm.vue +5 -2
  76. package/src/profile/components/SidRegistrationForm.test.ts +58 -1
  77. package/src/profile/components/SidRegistrationForm.vue +118 -12
  78. package/src/profile/components/index.ts +4 -0
  79. package/src/profile/composables/index.ts +2 -1
  80. package/src/profile/composables/useAccountApi.ts +20 -7
  81. package/src/profile/composables/useAuth.test.ts +453 -0
  82. package/src/profile/composables/useAuth.ts +414 -0
  83. package/src/profile/composables/useAuthApi.opaque.test.ts +18 -0
  84. package/src/profile/composables/useAuthApi.ts +188 -20
  85. package/src/profile/composables/useEnrollmentApi.test.ts +33 -0
  86. package/src/profile/composables/useEnrollmentApi.ts +26 -0
  87. package/src/profile/composables/useIdentityApi.ts +17 -7
  88. package/src/profile/composables/useLoginSuccess.test.ts +97 -3
  89. package/src/profile/composables/useLoginSuccess.ts +80 -22
  90. package/src/profile/composables/useSecurityLevel.test.ts +10 -3
  91. package/src/profile/composables/useSecurityLevel.ts +12 -3
  92. package/src/quasar/SidPrincipalInput.vue +4 -7
  93. package/src/utils/normalizePrincipal.test.ts +19 -5
  94. package/src/utils/normalizePrincipal.ts +7 -9
  95. package/src/utils/rpcRefusal.test.ts +151 -0
  96. package/src/utils/rpcRefusal.ts +252 -0
  97. package/src/utils/validators.test.ts +9 -0
  98. package/src/utils/validators.ts +14 -2
  99. package/src/composables/useAuth.ts +0 -40
  100. package/src/profile/composables/useOpaqueAuth.test.ts +0 -290
  101. package/src/profile/composables/useOpaqueAuth.ts +0 -191
@@ -0,0 +1,252 @@
1
+ /**
2
+ * What a refused gRPC call reports, in the terms the API contract fixes: the
3
+ * status code, the `(domain, reason)` pair of its `google.rpc.ErrorInfo` and
4
+ * the text for people. A page branches on the reason, never on the text.
5
+ */
6
+ import { RpcError } from "@protobuf-ts/runtime-rpc";
7
+ import { base64decode } from "@protobuf-ts/runtime";
8
+ import { Any } from "../generated/google/protobuf/any";
9
+ import { Status } from "../generated/google/rpc/status";
10
+ import {
11
+ ErrorInfo,
12
+ LocalizedMessage,
13
+ } from "../generated/google/rpc/error_details";
14
+ import { ErrorReason } from "../generated/sid/v1/common/errors";
15
+
16
+ export interface RpcRefusal {
17
+ /** The gRPC status code's name (`FAILED_PRECONDITION`). */
18
+ code: string;
19
+ /** The domain of the reason (`structured.id`), from `ErrorInfo`; null without one. */
20
+ domain: string | null;
21
+ /** The stable machine reason (`PASSWORD_REUSED`), from `ErrorInfo`; null without one. */
22
+ reason: string | null;
23
+ /** SID's reason as the generated enum; null for another domain or a reason this client does not know. */
24
+ reasonCode: ErrorReason | null;
25
+ /** The status message, written for developers; gRPC percent-encodes it on the wire. */
26
+ message: string;
27
+ /** The server's text for people (`LocalizedMessage`), when it sent one. */
28
+ localized: string | null;
29
+ }
30
+
31
+ /**
32
+ * The domain of SID's reasons, the same for every installation (AIP-193: a
33
+ * reason is defined only within its domain).
34
+ */
35
+ export const SID_ERROR_DOMAIN = "structured.id";
36
+
37
+ /** The trailer that carries the serialized `google.rpc.Status`, base64 like every binary metadata. */
38
+ const DETAILS_KEY = "grpc-status-details-bin";
39
+
40
+ /** The refusal behind `err`, or null when `err` is not a refused call. */
41
+ export function rpcRefusal(err: unknown): RpcRefusal | null {
42
+ if (!(err instanceof RpcError)) return null;
43
+ const refusal: RpcRefusal = {
44
+ code: err.code,
45
+ domain: null,
46
+ reason: null,
47
+ reasonCode: null,
48
+ message: decodeStatusMessage(err.message),
49
+ localized: null,
50
+ };
51
+ for (const detail of statusDetails(err.meta[DETAILS_KEY])) {
52
+ if (Any.contains(detail, ErrorInfo)) {
53
+ const info = Any.unpack(detail, ErrorInfo);
54
+ refusal.domain = info.domain;
55
+ refusal.reason = info.reason;
56
+ refusal.reasonCode =
57
+ info.domain === SID_ERROR_DOMAIN ? sidReason(info.reason) : null;
58
+ } else if (Any.contains(detail, LocalizedMessage)) {
59
+ refusal.localized = Any.unpack(detail, LocalizedMessage).message;
60
+ }
61
+ }
62
+ return refusal;
63
+ }
64
+
65
+ /**
66
+ * The stable reason of a refused call in `domain` (SID's by default); null for
67
+ * any other error or a reason of another domain.
68
+ */
69
+ export function rpcReason(
70
+ err: unknown,
71
+ domain: string = SID_ERROR_DOMAIN,
72
+ ): string | null {
73
+ const refusal = rpcRefusal(err);
74
+ return refusal?.domain === domain ? refusal.reason : null;
75
+ }
76
+
77
+ /**
78
+ * What each of SID's reasons means to the user who made the call. The
79
+ * status message is written for developers and is never shown; a reason
80
+ * added to the proto without a text here fails the type check.
81
+ */
82
+ export const REFUSAL_TEXTS: Readonly<
83
+ Record<Exclude<ErrorReason, ErrorReason.ERROR_REASON_UNSPECIFIED>, string>
84
+ > = {
85
+ [ErrorReason.PROFILE_NOT_FOUND]: "This account does not exist.",
86
+ [ErrorReason.APPLICATION_NOT_FOUND]: "This application does not exist.",
87
+ [ErrorReason.SESSION_NOT_FOUND]: "This session has ended.",
88
+ [ErrorReason.CREDENTIAL_NOT_FOUND]: "This credential does not exist.",
89
+ [ErrorReason.ROLE_NOT_FOUND]: "This role does not exist.",
90
+ [ErrorReason.PROJECT_NOT_FOUND]: "This project does not exist.",
91
+ [ErrorReason.OIDC_ISSUER_NOT_FOUND]: "This issuer does not exist.",
92
+ [ErrorReason.RESOURCE_NOT_FOUND]: "This protected resource does not exist.",
93
+ [ErrorReason.FORWARD_AUTH_APPLICATION_NOT_FOUND]:
94
+ "No protected application is configured for this address.",
95
+ [ErrorReason.INITIAL_ACCESS_TOKEN_NOT_FOUND]:
96
+ "This initial access token does not exist.",
97
+ [ErrorReason.UPSTREAM_PROVIDER_NOT_FOUND]:
98
+ "This identity provider does not exist.",
99
+ [ErrorReason.MACHINE_USER_NOT_FOUND]: "This service account does not exist.",
100
+ [ErrorReason.CONSENT_NOT_FOUND]: "You have not given consent to this site.",
101
+ [ErrorReason.PHONE_NOT_FOUND]: "This phone number is not on your account.",
102
+ [ErrorReason.EMAIL_NOT_FOUND]: "This email address is not on your account.",
103
+ [ErrorReason.DEVICE_NOT_FOUND]: "This device does not exist.",
104
+ [ErrorReason.AUTHENTICATION_FAILED]: "Sign-in failed.",
105
+ [ErrorReason.TOKEN_EXPIRED]: "Your session has expired; sign in again.",
106
+ [ErrorReason.TOKEN_INVALID]: "Your session is not valid; sign in again.",
107
+ [ErrorReason.INSUFFICIENT_PERMISSIONS]:
108
+ "You do not have permission to do this.",
109
+ [ErrorReason.SCOPE_NOT_GRANTED]: "This access was not granted.",
110
+ [ErrorReason.SIGN_IN_REFUSED]: "Sign-in is not allowed here.",
111
+ [ErrorReason.REGISTRATION_RESTRICTED]:
112
+ "Registration is restricted on this site.",
113
+ [ErrorReason.PROFILE_ALREADY_EXISTS]: "This account already exists.",
114
+ [ErrorReason.EMAIL_ALREADY_REGISTERED]:
115
+ "This email address is already registered.",
116
+ [ErrorReason.USERNAME_ALREADY_TAKEN]: "This username is taken.",
117
+ [ErrorReason.OPERATION_KEY_CONFLICT]:
118
+ "This request conflicts with an earlier one; start it again.",
119
+ [ErrorReason.APPLICATION_ROLE_EXISTS]:
120
+ "The application already has a role of this kind.",
121
+ [ErrorReason.RESOURCE_INDICATOR_TAKEN]:
122
+ "This resource indicator is already in use.",
123
+ [ErrorReason.PHONE_ALREADY_REGISTERED]:
124
+ "This phone number is already registered.",
125
+ [ErrorReason.ROLE_ALREADY_EXISTS]:
126
+ "A role with this key or name already exists.",
127
+ [ErrorReason.UPSTREAM_PROVIDER_ALREADY_EXISTS]:
128
+ "An identity provider with this name already exists.",
129
+ [ErrorReason.PRINCIPAL_ALREADY_HELD]:
130
+ "This sign-in handle belongs to another account.",
131
+ [ErrorReason.INVALID_FIELD_VALUE]: "Some of the values are not valid.",
132
+ [ErrorReason.REQUIRED_FIELD_MISSING]: "A required value is missing.",
133
+ [ErrorReason.PASSWORD_PROOF_INVALID]:
134
+ "The password could not be proven to the server; try again.",
135
+ [ErrorReason.MFA_REQUIRED]: "Set up a second factor first.",
136
+ [ErrorReason.ACCOUNT_SUSPENDED]: "This account is suspended.",
137
+ [ErrorReason.ACCOUNT_LOCKED]:
138
+ "This account is locked after too many attempts; try again later.",
139
+ [ErrorReason.CONSENT_REQUIRED]: "Your consent is required first.",
140
+ [ErrorReason.CREDENTIAL_REVOKED]: "This credential was revoked.",
141
+ [ErrorReason.ACCOUNT_CLOSED]: "This account is closed.",
142
+ [ErrorReason.OPERATION_RESULT_UNAVAILABLE]:
143
+ "This operation completed, but its result is no longer available.",
144
+ [ErrorReason.OPERATION_EXPIRED]: "This request has expired; start it again.",
145
+ [ErrorReason.RESOURCE_RETIRED]: "This protected resource is retired.",
146
+ [ErrorReason.ISSUER_MISMATCH]:
147
+ "The application and the resource belong to different issuers.",
148
+ [ErrorReason.SYSTEM_INTEGRATION_UNAVAILABLE]:
149
+ "The installation's own integration is not available.",
150
+ [ErrorReason.SYSTEM_MANAGED]:
151
+ "This is managed by the installation and cannot be changed by hand.",
152
+ [ErrorReason.PASSWORD_REUSED]:
153
+ "This password was used on this account before; choose another.",
154
+ [ErrorReason.STEP_UP_REQUIRED]: "Confirm your identity again to continue.",
155
+ [ErrorReason.CAPTCHA_REQUIRED]: "Complete the challenge to continue.",
156
+ [ErrorReason.LEGACY_MIGRATION_REQUIRED]:
157
+ "Your password needs to be updated before you continue.",
158
+ [ErrorReason.INVALID_STATE]: "This cannot be done in its current state.",
159
+ [ErrorReason.FEATURE_NOT_CONFIGURED]:
160
+ "This feature is not configured; an administrator can enable it.",
161
+ [ErrorReason.RATE_LIMIT_EXCEEDED]: "Too many attempts; try again later.",
162
+ [ErrorReason.QUOTA_EXCEEDED]: "A limit is reached; remove something first.",
163
+ [ErrorReason.CONCURRENT_MODIFICATION]:
164
+ "This was changed by someone else; reload and try again.",
165
+ [ErrorReason.OPERATION_IN_PROGRESS]:
166
+ "This operation is still running; try again shortly.",
167
+ [ErrorReason.PASSWORD_HISTORY_UNAVAILABLE]:
168
+ "The password history cannot be checked right now; try again later.",
169
+ [ErrorReason.SERVICE_MAINTENANCE]:
170
+ "The service is in maintenance; try again later.",
171
+ [ErrorReason.DEPENDENCY_UNAVAILABLE]:
172
+ "The service is temporarily unavailable; try again later.",
173
+ [ErrorReason.PRINCIPAL_NOT_FOUND]:
174
+ "This sign-in handle is not on your account.",
175
+ [ErrorReason.EXPORT_NOT_FOUND]: "There is no data export for your account.",
176
+ [ErrorReason.CLOSURE_REQUEST_NOT_FOUND]:
177
+ "There is no closure request to cancel.",
178
+ [ErrorReason.GROUP_NOT_FOUND]: "This group does not exist.",
179
+ [ErrorReason.POLICY_NOT_FOUND]: "This policy does not exist.",
180
+ [ErrorReason.ACCESS_REQUEST_NOT_FOUND]: "This access request does not exist.",
181
+ [ErrorReason.TEMPLATE_NOT_FOUND]: "This template does not exist.",
182
+ [ErrorReason.ATTESTATION_NOT_FOUND]: "This device has no registered key.",
183
+ [ErrorReason.PROFILE_METADATA_NOT_FOUND]:
184
+ "This metadata entry does not exist.",
185
+ [ErrorReason.BRANDING_NOT_FOUND]: "This branding does not exist.",
186
+ [ErrorReason.FLOW_CONFIG_NOT_FOUND]: "This flow has no saved configuration.",
187
+ [ErrorReason.FLOW_ACTION_NOT_FOUND]: "This flow action does not exist.",
188
+ [ErrorReason.INVITE_NOT_FOUND]: "This invite does not exist.",
189
+ [ErrorReason.ORGANIZATION_NOT_FOUND]:
190
+ "This organization does not exist or you are not a member.",
191
+ [ErrorReason.PROVISIONING_CONNECTOR_NOT_FOUND]:
192
+ "This provisioning connector does not exist.",
193
+ [ErrorReason.GROUP_ALREADY_EXISTS]: "A group with this name already exists.",
194
+ [ErrorReason.POLICY_ALREADY_EXISTS]:
195
+ "A policy with this name already exists.",
196
+ [ErrorReason.ATTESTATION_ALREADY_EXISTS]:
197
+ "This device already has a registered key; rotate it instead.",
198
+ [ErrorReason.OPERATION_OUTCOME_UNKNOWN]:
199
+ "The outcome is not known yet; try the same request again.",
200
+ [ErrorReason.FEATURE_NOT_AVAILABLE]:
201
+ "This feature is not part of this edition.",
202
+ [ErrorReason.INTERNAL_ERROR]: "Something went wrong on the server.",
203
+ };
204
+
205
+ /**
206
+ * The text to show for a failed call: the server's text for people, else the
207
+ * client's text for SID's reason, else `fallback`, the caller's context. A
208
+ * client-side `Error` (no call refused) keeps its own message.
209
+ */
210
+ export function refusalMessage(
211
+ err: unknown,
212
+ fallback = "Request failed",
213
+ ): string {
214
+ const refusal = rpcRefusal(err);
215
+ if (refusal) {
216
+ if (refusal.localized) return refusal.localized;
217
+ if (
218
+ refusal.reasonCode !== null &&
219
+ refusal.reasonCode !== ErrorReason.ERROR_REASON_UNSPECIFIED
220
+ ) {
221
+ return REFUSAL_TEXTS[refusal.reasonCode];
222
+ }
223
+ return fallback;
224
+ }
225
+ return err instanceof Error ? err.message : fallback;
226
+ }
227
+
228
+ /** The generated member named `reason`; null for a name this client does not know. */
229
+ function sidReason(reason: string): ErrorReason | null {
230
+ const code = (ErrorReason as unknown as Record<string, unknown>)[reason];
231
+ return typeof code === "number" ? (code as ErrorReason) : null;
232
+ }
233
+
234
+ /** The transport hands the status message over as the wire carries it: percent-encoded. */
235
+ function decodeStatusMessage(message: string): string {
236
+ try {
237
+ return decodeURIComponent(message);
238
+ } catch {
239
+ return message;
240
+ }
241
+ }
242
+
243
+ /** The details of the status in the trailer; none when the trailer is missing or unreadable. */
244
+ function statusDetails(value: string | string[] | undefined): Any[] {
245
+ const encoded = Array.isArray(value) ? value[0] : value;
246
+ if (!encoded) return [];
247
+ try {
248
+ return Status.fromBinary(base64decode(encoded)).details;
249
+ } catch {
250
+ return [];
251
+ }
252
+ }
@@ -19,6 +19,15 @@ describe("isValidEmail", () => {
19
19
  expect(isValidEmail("noat")).toBe(false);
20
20
  expect(isValidEmail("@no-local.com")).toBe(false);
21
21
  expect(isValidEmail("spaces in@email.com")).toBe(false);
22
+ expect(isValidEmail("user@")).toBe(false);
23
+ expect(isValidEmail("user@exa mple.com")).toBe(false);
24
+ });
25
+ // Regression: a dot was required in the domain and quoted local parts were
26
+ // refused, so the client turned away addresses the server's policy admits.
27
+ it("leaves admission to the server's email policy", () => {
28
+ expect(isValidEmail("admin@printer")).toBe(true);
29
+ expect(isValidEmail('"a b"@example.com')).toBe(true);
30
+ expect(isValidEmail('"a@b"@example.com')).toBe(true);
22
31
  });
23
32
  });
24
33
 
@@ -1,6 +1,18 @@
1
- /** Email validation (RFC 5322 simplified). */
1
+ /**
2
+ * Whether `value` has the shape of one mailbox: a local part and a domain
3
+ * around the last `@`, whitespace only inside a quoted local part. Advisory:
4
+ * the server's email policy decides admission (internal single-label domains
5
+ * and quoted local parts may be admitted), so this refuses only what can
6
+ * never be an address.
7
+ */
2
8
  export function isValidEmail(value: string): boolean {
3
- return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
9
+ const at = value.lastIndexOf("@");
10
+ if (at <= 0 || at === value.length - 1) return false;
11
+ const local = value.slice(0, at);
12
+ const domain = value.slice(at + 1);
13
+ const quoted =
14
+ local.length > 1 && local.startsWith('"') && local.endsWith('"');
15
+ return !/\s/.test(domain) && (quoted || !/\s/.test(local));
4
16
  }
5
17
 
6
18
  /** Phone number validation (E.164 format). */
@@ -1,40 +0,0 @@
1
- import { computed } from "vue";
2
- import { useAuthStore } from "../stores/auth";
3
-
4
- /**
5
- * Vue composable for authentication state and actions.
6
- *
7
- * Wraps the Pinia auth store with reactive computed properties.
8
- * Post BFF migration (#536i-3): no token accessors — session validity
9
- * is based on httpOnly cookie checked via BFF /auth/userinfo on boot.
10
- *
11
- * @example
12
- * ```vue
13
- * <script setup>
14
- * import { useAuth } from '@structured-id/ui-core'
15
- * const { isAuthenticated, displayName, logout } = useAuth()
16
- * </script>
17
- * ```
18
- */
19
- export function useAuth() {
20
- const store = useAuthStore();
21
-
22
- const isAuthenticated = computed(() => store.isAuthenticated);
23
- const displayName = computed(() => store.displayName);
24
- const email = computed(() => store.email);
25
- const profileId = computed(() => store.profileId);
26
- const preferredUsername = computed(() => store.preferredUsername);
27
- const sessionId = computed(() => store.sessionId);
28
-
29
- return {
30
- isAuthenticated,
31
- displayName,
32
- email,
33
- profileId,
34
- preferredUsername,
35
- sessionId,
36
- setBffSession: store.setBffSession,
37
- setProfile: store.setProfile,
38
- logout: store.logout,
39
- };
40
- }
@@ -1,290 +0,0 @@
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
- });