@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,191 @@
1
+ /**
2
+ * OPAQUE auth flow orchestration (shared between all profile UIs).
3
+ *
4
+ * Uses dependency injection for client-side crypto: apps provide an
5
+ * OpaqueClientApi implementation (from @structured-id/opaque), keeping
6
+ * the WASM dependency at the app layer.
7
+ *
8
+ * gRPC calls go through useAuthApi wrappers.
9
+ */
10
+ import {
11
+ opaqueLoginStart,
12
+ opaqueLoginFinish,
13
+ opaqueRegistrationStart,
14
+ opaqueRegistrationFinish,
15
+ } from "./useAuthApi";
16
+
17
+ // ── Types ──
18
+
19
+ export interface LoginResult {
20
+ sessionId: string;
21
+ accessToken: string;
22
+ expiresIn: number;
23
+ }
24
+
25
+ export interface RegisterResult {
26
+ profileId: string;
27
+ credentialId: string;
28
+ }
29
+
30
+ /**
31
+ * Interface for OPAQUE client-side crypto operations.
32
+ * Implemented by @structured-id/opaque OpaqueClient.
33
+ */
34
+ export interface OpaqueClientApi {
35
+ loginStart(password: string): Promise<{ ke1: Uint8Array; state: unknown }>;
36
+ loginFinish(
37
+ password: string,
38
+ ke2: Uint8Array,
39
+ state: unknown,
40
+ ): Promise<{ ke3: Uint8Array }>;
41
+ registrationStart(
42
+ password: string,
43
+ ): Promise<{ request: Uint8Array; state: unknown }>;
44
+ registrationFinish(
45
+ password: string,
46
+ response: Uint8Array,
47
+ state: unknown,
48
+ ): Promise<{ record: Uint8Array }>;
49
+ }
50
+
51
+ export type OpaqueClientFactory = (
52
+ serverId: string,
53
+ clientId: string,
54
+ ) => OpaqueClientApi;
55
+
56
+ /**
57
+ * Create an OPAQUE auth composable with injected crypto client.
58
+ *
59
+ * @param createClient - Factory that creates an OpaqueClientApi instance
60
+ * @param serverId - OPAQUE server identifier (default: "structured.id")
61
+ *
62
+ * @example
63
+ * ```ts
64
+ * import { OpaqueClient } from "@structured-id/opaque";
65
+ * import { createOpaqueAuth } from "@structured-id/ui-core/profile";
66
+ *
67
+ * const { login, register } = createOpaqueAuth(
68
+ * (serverId, clientId) => new OpaqueClient({ serverId, clientId }),
69
+ * );
70
+ * ```
71
+ */
72
+ export function createOpaqueAuth(
73
+ createClient: OpaqueClientFactory,
74
+ serverId = "structured.id",
75
+ ) {
76
+ /**
77
+ * Perform full OPAQUE login flow (2 round-trips via gRPC-web).
78
+ *
79
+ * 1. loginStart: send credentialRequest → receive credentialResponse + serverLoginState
80
+ * 2. loginFinish: send credentialFinalization → receive session tokens
81
+ */
82
+ async function login(
83
+ identifier: string,
84
+ password: string,
85
+ onStep?: (step: number) => void,
86
+ ): Promise<LoginResult> {
87
+ const opaque = createClient(serverId, identifier);
88
+
89
+ // Step 1: Generate credential request (client-side only, password never sent)
90
+ onStep?.(1);
91
+ const start = await opaque.loginStart(password);
92
+
93
+ // Step 2: Send credential request to server
94
+ onStep?.(2);
95
+ const startResp = await opaqueLoginStart(identifier, start.ke1);
96
+
97
+ // Step 3: Process server response, generate finalization (client-side crypto)
98
+ onStep?.(3);
99
+ const finish = await opaque.loginFinish(
100
+ password,
101
+ startResp.credentialResponse,
102
+ start.state,
103
+ );
104
+
105
+ // Step 4: Send finalization, receive session tokens
106
+ const finishResp = await opaqueLoginFinish(
107
+ identifier,
108
+ finish.ke3,
109
+ startResp.serverLoginState,
110
+ );
111
+
112
+ return {
113
+ sessionId: finishResp.sessionId,
114
+ accessToken: finishResp.accessToken,
115
+ expiresIn: Number(finishResp.expiresIn),
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Perform full OPAQUE registration flow (2 round-trips via gRPC-web).
121
+ *
122
+ * 1. registrationStart: send registrationRequest → receive registrationResponse + serverSetup
123
+ * 2. registrationFinish: send registrationRecord → receive profileId + credentialId
124
+ */
125
+ async function register(
126
+ identifier: string,
127
+ password: string,
128
+ ): Promise<RegisterResult> {
129
+ const opaque = createClient(serverId, identifier);
130
+
131
+ // Step 1: Generate registration request
132
+ const start = await opaque.registrationStart(password);
133
+
134
+ const startResp = await opaqueRegistrationStart(
135
+ identifier,
136
+ start.request,
137
+ );
138
+
139
+ // Step 2: Process server response, generate registration record
140
+ const finish = await opaque.registrationFinish(
141
+ password,
142
+ startResp.registrationResponse,
143
+ start.state,
144
+ );
145
+
146
+ const finishResp = await opaqueRegistrationFinish(
147
+ identifier,
148
+ finish.record,
149
+ startResp.serverSetup,
150
+ );
151
+
152
+ return {
153
+ profileId: finishResp.profileId,
154
+ credentialId: finishResp.credentialId,
155
+ };
156
+ }
157
+
158
+ /**
159
+ * Compute OPAQUE registration record WITHOUT storing the credential.
160
+ *
161
+ * Used by password reset: the registration_record is passed to
162
+ * CompletePasswordReset RPC which stores the credential in the
163
+ * context of the reset session (replacing the old one).
164
+ *
165
+ * 1. registrationStart: send registrationRequest → receive registrationResponse
166
+ * 2. Client-side finish: compute registrationRecord (NOT sent to server yet)
167
+ */
168
+ async function computeRegistrationRecord(
169
+ identifier: string,
170
+ password: string,
171
+ ): Promise<Uint8Array> {
172
+ const opaque = createClient(serverId, identifier);
173
+
174
+ const start = await opaque.registrationStart(password);
175
+
176
+ const startResp = await opaqueRegistrationStart(
177
+ identifier,
178
+ start.request,
179
+ );
180
+
181
+ const finish = await opaque.registrationFinish(
182
+ password,
183
+ startResp.registrationResponse,
184
+ start.state,
185
+ );
186
+
187
+ return finish.record;
188
+ }
189
+
190
+ return { login, register, computeRegistrationRecord };
191
+ }
@@ -0,0 +1,198 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { ref } from "vue";
3
+ import {
4
+ useSecurityLevel,
5
+ SecurityLevel,
6
+ RecoverabilityLevel,
7
+ } from "./useSecurityLevel";
8
+ import {
9
+ CredentialType,
10
+ AuthenticatorAttachment,
11
+ PrincipalType,
12
+ type Credential,
13
+ type Principal,
14
+ } from "../../index";
15
+ import { PrincipalSubjectType } from "../../generated/sid/v1/identity/identity";
16
+
17
+ // ── Helpers ──
18
+
19
+ function makeCred(
20
+ type: CredentialType,
21
+ opts?: { attachment?: AuthenticatorAttachment; backupState?: boolean },
22
+ ): Credential {
23
+ const cred: Credential = {
24
+ id: "cred-1",
25
+ profileId: "",
26
+ type,
27
+ data: new Uint8Array(),
28
+ info: { oneofKind: undefined },
29
+ };
30
+ if (type === CredentialType.WEBAUTHN) {
31
+ cred.info = {
32
+ oneofKind: "webauthnInfo",
33
+ webauthnInfo: {
34
+ attachment: opts?.attachment ?? AuthenticatorAttachment.PLATFORM,
35
+ backupState: opts?.backupState ?? false,
36
+ backupEligible: false,
37
+ userVerified: true,
38
+ transports: [],
39
+ attestationFormat: "",
40
+ },
41
+ };
42
+ }
43
+ return cred;
44
+ }
45
+
46
+ function makePrincipal(type: PrincipalType, verified: boolean): Principal {
47
+ return {
48
+ id: "p-1",
49
+ type,
50
+ value: "test",
51
+ verified,
52
+ isPrimary: false,
53
+ subjectType: PrincipalSubjectType.PROFILE,
54
+ subjectId: "",
55
+ };
56
+ }
57
+
58
+ // ── Security Level ──
59
+
60
+ describe("useSecurityLevel", () => {
61
+ it("MINIMAL with no credentials", () => {
62
+ const { securityLevel } = useSecurityLevel(ref([]), ref([]));
63
+ expect(securityLevel.value).toBe(SecurityLevel.MINIMAL);
64
+ });
65
+
66
+ it("MINIMAL with only password", () => {
67
+ const creds = ref([makeCred(CredentialType.OPAQUE)]);
68
+ const { securityLevel, factorCount } = useSecurityLevel(creds, ref([]));
69
+ expect(securityLevel.value).toBe(SecurityLevel.MINIMAL);
70
+ expect(factorCount.value).toBe(1);
71
+ });
72
+
73
+ it("BASIC with password + TOTP (2 factors, 2 categories)", () => {
74
+ const creds = ref([
75
+ makeCred(CredentialType.OPAQUE),
76
+ makeCred(CredentialType.TOTP),
77
+ ]);
78
+ const { securityLevel } = useSecurityLevel(creds, ref([]));
79
+ expect(securityLevel.value).toBe(SecurityLevel.STANDARD);
80
+ });
81
+
82
+ it("STRONG with 3 factors, 2 categories", () => {
83
+ const creds = ref([
84
+ makeCred(CredentialType.OPAQUE),
85
+ makeCred(CredentialType.TOTP),
86
+ makeCred(CredentialType.WEBAUTHN, {
87
+ attachment: AuthenticatorAttachment.PLATFORM,
88
+ }),
89
+ ]);
90
+ const { securityLevel } = useSecurityLevel(creds, ref([]));
91
+ expect(securityLevel.value).toBe(SecurityLevel.STRONG);
92
+ });
93
+
94
+ it("MAXIMUM with 3 factors, 3 categories, hardware key", () => {
95
+ const creds = ref([
96
+ makeCred(CredentialType.OPAQUE), // knowledge
97
+ makeCred(CredentialType.WEBAUTHN, {
98
+ attachment: AuthenticatorAttachment.PLATFORM,
99
+ }), // inherence
100
+ makeCred(CredentialType.WEBAUTHN, {
101
+ attachment: AuthenticatorAttachment.CROSS_PLATFORM,
102
+ }), // possession (hardware)
103
+ ]);
104
+ const { securityLevel } = useSecurityLevel(creds, ref([]));
105
+ expect(securityLevel.value).toBe(SecurityLevel.MAXIMUM);
106
+ });
107
+
108
+ it("recovery codes not counted as auth factor", () => {
109
+ const creds = ref([
110
+ makeCred(CredentialType.OPAQUE),
111
+ makeCred(CredentialType.RECOVERY),
112
+ ]);
113
+ const { factorCount } = useSecurityLevel(creds, ref([]));
114
+ expect(factorCount.value).toBe(1); // only password
115
+ });
116
+ });
117
+
118
+ // ── Recoverability ──
119
+
120
+ describe("recoverability", () => {
121
+ it("NONE with no recovery methods", () => {
122
+ const { recoverabilityLevel } = useSecurityLevel(ref([]), ref([]));
123
+ expect(recoverabilityLevel.value).toBe(RecoverabilityLevel.NONE);
124
+ });
125
+
126
+ it("FRAGILE with only recovery codes", () => {
127
+ const creds = ref([makeCred(CredentialType.RECOVERY)]);
128
+ const { recoverabilityLevel } = useSecurityLevel(creds, ref([]));
129
+ expect(recoverabilityLevel.value).toBe(RecoverabilityLevel.FRAGILE);
130
+ });
131
+
132
+ it("STANDARD with recovery codes + verified email", () => {
133
+ const creds = ref([makeCred(CredentialType.RECOVERY)]);
134
+ const principals = ref([makePrincipal(PrincipalType.EMAIL, true)]);
135
+ const { recoverabilityLevel } = useSecurityLevel(creds, principals);
136
+ expect(recoverabilityLevel.value).toBe(RecoverabilityLevel.STANDARD);
137
+ });
138
+
139
+ it("ROBUST with codes + email + phone", () => {
140
+ const creds = ref([makeCred(CredentialType.RECOVERY)]);
141
+ const principals = ref([
142
+ makePrincipal(PrincipalType.EMAIL, true),
143
+ makePrincipal(PrincipalType.PHONE, true),
144
+ ]);
145
+ const { recoverabilityLevel } = useSecurityLevel(creds, principals);
146
+ expect(recoverabilityLevel.value).toBe(RecoverabilityLevel.ROBUST);
147
+ });
148
+
149
+ it("synced passkey counts as recovery method", () => {
150
+ const creds = ref([
151
+ makeCred(CredentialType.WEBAUTHN, {
152
+ attachment: AuthenticatorAttachment.PLATFORM,
153
+ backupState: true,
154
+ }),
155
+ ]);
156
+ const { recoveryMethods } = useSecurityLevel(creds, ref([]));
157
+ expect(recoveryMethods.value).toContain("synced_passkey");
158
+ });
159
+
160
+ it("unverified email not counted", () => {
161
+ const principals = ref([makePrincipal(PrincipalType.EMAIL, false)]);
162
+ const { recoveryMethods } = useSecurityLevel(ref([]), principals);
163
+ expect(recoveryMethods.value).not.toContain("recovery_email");
164
+ });
165
+ });
166
+
167
+ // ── Nudges ──
168
+
169
+ describe("nudges", () => {
170
+ it("suggests recovery setup when no recovery codes", () => {
171
+ const creds = ref([makeCred(CredentialType.OPAQUE)]);
172
+ const { nudges } = useSecurityLevel(creds, ref([]));
173
+ expect(nudges.value.some((n) => n.id === "setup_recovery")).toBe(true);
174
+ });
175
+
176
+ it("suggests MFA when only password", () => {
177
+ const creds = ref([makeCred(CredentialType.OPAQUE)]);
178
+ const { nudges } = useSecurityLevel(creds, ref([]));
179
+ expect(nudges.value.some((n) => n.id === "setup_mfa")).toBe(true);
180
+ });
181
+
182
+ it("no MFA nudge when passkey exists", () => {
183
+ const creds = ref([
184
+ makeCred(CredentialType.OPAQUE),
185
+ makeCred(CredentialType.WEBAUTHN),
186
+ ]);
187
+ const { nudges } = useSecurityLevel(creds, ref([]));
188
+ expect(nudges.value.some((n) => n.id === "setup_mfa")).toBe(false);
189
+ });
190
+
191
+ it("nudges sorted by priority", () => {
192
+ const creds = ref([makeCred(CredentialType.OPAQUE)]);
193
+ const principals = ref([makePrincipal(PrincipalType.EMAIL, false)]);
194
+ const { nudges } = useSecurityLevel(creds, principals);
195
+ const priorities = nudges.value.map((n) => n.priority);
196
+ expect(priorities).toEqual([...priorities].sort((a, b) => a - b));
197
+ });
198
+ });
@@ -0,0 +1,324 @@
1
+ /**
2
+ * Security level and recoverability assessment composable.
3
+ *
4
+ * Pure computation — no API calls, no auth dependency.
5
+ * Analyzes credentials and principals to determine account security posture.
6
+ */
7
+ import { computed, type Ref } from "vue";
8
+ import {
9
+ type Credential,
10
+ type Principal,
11
+ CredentialType,
12
+ AuthenticatorAttachment,
13
+ PrincipalType,
14
+ } from "../../index";
15
+
16
+ // ── Security Level ──
17
+
18
+ export enum SecurityLevel {
19
+ MINIMAL = 1,
20
+ BASIC = 2,
21
+ STANDARD = 3,
22
+ STRONG = 4,
23
+ MAXIMUM = 5,
24
+ }
25
+
26
+ const SECURITY_LEVEL_LABELS: Record<SecurityLevel, string> = {
27
+ [SecurityLevel.MINIMAL]: "Minimal",
28
+ [SecurityLevel.BASIC]: "Basic",
29
+ [SecurityLevel.STANDARD]: "Standard",
30
+ [SecurityLevel.STRONG]: "Strong",
31
+ [SecurityLevel.MAXIMUM]: "Maximum",
32
+ };
33
+
34
+ const SECURITY_LEVEL_COLORS: Record<SecurityLevel, string> = {
35
+ [SecurityLevel.MINIMAL]: "negative",
36
+ [SecurityLevel.BASIC]: "warning",
37
+ [SecurityLevel.STANDARD]: "info",
38
+ [SecurityLevel.STRONG]: "positive",
39
+ [SecurityLevel.MAXIMUM]: "positive",
40
+ };
41
+
42
+ // ── Recoverability Level ──
43
+
44
+ export enum RecoverabilityLevel {
45
+ NONE = 0,
46
+ FRAGILE = 1,
47
+ STANDARD = 2,
48
+ ROBUST = 3,
49
+ }
50
+
51
+ const RECOVERABILITY_LABELS: Record<RecoverabilityLevel, string> = {
52
+ [RecoverabilityLevel.NONE]: "None",
53
+ [RecoverabilityLevel.FRAGILE]: "Fragile",
54
+ [RecoverabilityLevel.STANDARD]: "Standard",
55
+ [RecoverabilityLevel.ROBUST]: "Robust",
56
+ };
57
+
58
+ const RECOVERABILITY_COLORS: Record<RecoverabilityLevel, string> = {
59
+ [RecoverabilityLevel.NONE]: "negative",
60
+ [RecoverabilityLevel.FRAGILE]: "warning",
61
+ [RecoverabilityLevel.STANDARD]: "info",
62
+ [RecoverabilityLevel.ROBUST]: "positive",
63
+ };
64
+
65
+ // ── Factor Categories ──
66
+
67
+ export type FactorCategory = "knowledge" | "possession" | "inherence";
68
+
69
+ function getCategory(cred: Credential): FactorCategory | null {
70
+ switch (cred.type) {
71
+ case CredentialType.OPAQUE:
72
+ case CredentialType.LEGACY_HASH:
73
+ return "knowledge";
74
+ case CredentialType.TOTP:
75
+ return "possession";
76
+ case CredentialType.WEBAUTHN: {
77
+ if (cred.info.oneofKind === "webauthnInfo") {
78
+ return cred.info.webauthnInfo.attachment ===
79
+ AuthenticatorAttachment.PLATFORM
80
+ ? "inherence"
81
+ : "possession";
82
+ }
83
+ return "possession";
84
+ }
85
+ case CredentialType.RECOVERY:
86
+ return null; // recovery codes are not an auth factor
87
+ default:
88
+ return null;
89
+ }
90
+ }
91
+
92
+ function isHardwareKey(cred: Credential): boolean {
93
+ return (
94
+ cred.type === CredentialType.WEBAUTHN &&
95
+ cred.info.oneofKind === "webauthnInfo" &&
96
+ cred.info.webauthnInfo.attachment === AuthenticatorAttachment.CROSS_PLATFORM
97
+ );
98
+ }
99
+
100
+ // ── Action Nudge ──
101
+
102
+ export interface ActionNudge {
103
+ id: string;
104
+ icon: string;
105
+ label: string;
106
+ description: string;
107
+ color: string;
108
+ priority: number;
109
+ to?: string;
110
+ action?: string;
111
+ }
112
+
113
+ // ── Composable ──
114
+
115
+ export function useSecurityLevel(
116
+ credentials: Ref<Credential[]>,
117
+ identifiers: Ref<Principal[]>,
118
+ ) {
119
+ const authFactors = computed(() =>
120
+ credentials.value.filter((c) => c.type !== CredentialType.RECOVERY),
121
+ );
122
+
123
+ const factorCount = computed(() => authFactors.value.length);
124
+
125
+ const categories = computed(() => {
126
+ const cats = new Set<FactorCategory>();
127
+ for (const cred of authFactors.value) {
128
+ const cat = getCategory(cred);
129
+ if (cat) cats.add(cat);
130
+ }
131
+ return cats;
132
+ });
133
+
134
+ const hasHardwareKey = computed(() => authFactors.value.some(isHardwareKey));
135
+
136
+ const securityLevel = computed<SecurityLevel>(() => {
137
+ const count = factorCount.value;
138
+ const catCount = categories.value.size;
139
+
140
+ if (count >= 3 && catCount >= 3 && hasHardwareKey.value) {
141
+ return SecurityLevel.MAXIMUM;
142
+ }
143
+ if (count >= 3 && catCount >= 2) {
144
+ return SecurityLevel.STRONG;
145
+ }
146
+ if (count >= 2 && catCount >= 2) {
147
+ return SecurityLevel.STANDARD;
148
+ }
149
+ if (count >= 2) {
150
+ return SecurityLevel.BASIC;
151
+ }
152
+ return SecurityLevel.MINIMAL;
153
+ });
154
+
155
+ const securityLabel = computed(
156
+ () => SECURITY_LEVEL_LABELS[securityLevel.value],
157
+ );
158
+ const securityColor = computed(
159
+ () => SECURITY_LEVEL_COLORS[securityLevel.value],
160
+ );
161
+ const securityProgress = computed(() => securityLevel.value / 5);
162
+
163
+ // ── Recoverability ──
164
+
165
+ const recoveryMethods = computed(() => {
166
+ const methods: string[] = [];
167
+
168
+ if (credentials.value.some((c) => c.type === CredentialType.RECOVERY)) {
169
+ methods.push("recovery_codes");
170
+ }
171
+
172
+ if (
173
+ credentials.value.some(
174
+ (c) =>
175
+ c.type === CredentialType.WEBAUTHN &&
176
+ c.info.oneofKind === "webauthnInfo" &&
177
+ c.info.webauthnInfo.backupState,
178
+ )
179
+ ) {
180
+ methods.push("synced_passkey");
181
+ }
182
+
183
+ const emails = identifiers.value.filter(
184
+ (i) => i.type === PrincipalType.EMAIL && i.verified,
185
+ );
186
+ if (emails.length > 0) {
187
+ methods.push("recovery_email");
188
+ }
189
+
190
+ const phones = identifiers.value.filter(
191
+ (i) => i.type === PrincipalType.PHONE && i.verified,
192
+ );
193
+ if (phones.length > 0) {
194
+ methods.push("recovery_phone");
195
+ }
196
+
197
+ return methods;
198
+ });
199
+
200
+ const hasDeviceIndependent = computed(() =>
201
+ recoveryMethods.value.some((m) =>
202
+ ["recovery_codes", "recovery_email", "recovery_phone"].includes(m),
203
+ ),
204
+ );
205
+
206
+ const recoverabilityLevel = computed<RecoverabilityLevel>(() => {
207
+ const count = recoveryMethods.value.length;
208
+ if (count >= 3) return RecoverabilityLevel.ROBUST;
209
+ if (count >= 2 && hasDeviceIndependent.value)
210
+ return RecoverabilityLevel.STANDARD;
211
+ if (count >= 1) return RecoverabilityLevel.FRAGILE;
212
+ return RecoverabilityLevel.NONE;
213
+ });
214
+
215
+ const recoverabilityLabel = computed(
216
+ () => RECOVERABILITY_LABELS[recoverabilityLevel.value],
217
+ );
218
+ const recoverabilityColor = computed(
219
+ () => RECOVERABILITY_COLORS[recoverabilityLevel.value],
220
+ );
221
+ const recoverabilityProgress = computed(() => recoverabilityLevel.value / 3);
222
+
223
+ // ── Action Nudges ──
224
+
225
+ const nudges = computed<ActionNudge[]>(() => {
226
+ const items: ActionNudge[] = [];
227
+
228
+ const hasPasskey = credentials.value.some(
229
+ (c) => c.type === CredentialType.WEBAUTHN,
230
+ );
231
+ const hasTotp = credentials.value.some(
232
+ (c) => c.type === CredentialType.TOTP,
233
+ );
234
+ const hasRecovery = credentials.value.some(
235
+ (c) => c.type === CredentialType.RECOVERY,
236
+ );
237
+ const verifiedEmails = identifiers.value.filter(
238
+ (i) => i.type === PrincipalType.EMAIL && i.verified,
239
+ );
240
+ const unverifiedEmails = identifiers.value.filter(
241
+ (i) => i.type === PrincipalType.EMAIL && !i.verified,
242
+ );
243
+
244
+ if (!hasRecovery) {
245
+ items.push({
246
+ id: "setup_recovery",
247
+ icon: "sym_o_key",
248
+ label: "Set up recovery",
249
+ description:
250
+ "Generate recovery codes to regain access if you lose your credentials",
251
+ color: "negative",
252
+ priority: 1,
253
+ action: "generate_recovery",
254
+ });
255
+ }
256
+
257
+ if (!hasPasskey && !hasTotp) {
258
+ items.push({
259
+ id: "setup_mfa",
260
+ icon: "sym_o_shield",
261
+ label: "Enable multi-factor authentication",
262
+ description:
263
+ "Add a second factor to protect your account from unauthorized access",
264
+ color: "warning",
265
+ priority: 2,
266
+ to: "/account/security",
267
+ });
268
+ }
269
+
270
+ if (unverifiedEmails.length > 0) {
271
+ items.push({
272
+ id: "verify_email",
273
+ icon: "sym_o_mark_email_read",
274
+ label: "Verify your email",
275
+ description: "Confirm your email address for account recovery",
276
+ color: "warning",
277
+ priority: 3,
278
+ to: "/account/profile",
279
+ });
280
+ }
281
+
282
+ if (!hasPasskey) {
283
+ items.push({
284
+ id: "add_passkey",
285
+ icon: "sym_o_passkey",
286
+ label: "Add a passkey",
287
+ description:
288
+ "Passkeys provide phishing-resistant, passwordless sign-in",
289
+ color: "info",
290
+ priority: 4,
291
+ to: "/auth/webauthn-enrollment",
292
+ });
293
+ }
294
+
295
+ if (verifiedEmails.length < 2 && unverifiedEmails.length === 0) {
296
+ items.push({
297
+ id: "add_backup_email",
298
+ icon: "sym_o_alternate_email",
299
+ label: "Add a backup email",
300
+ description: "A secondary email helps with account recovery",
301
+ color: "grey",
302
+ priority: 5,
303
+ to: "/account/profile",
304
+ });
305
+ }
306
+
307
+ return items.sort((a, b) => a.priority - b.priority);
308
+ });
309
+
310
+ return {
311
+ securityLevel,
312
+ securityLabel,
313
+ securityColor,
314
+ securityProgress,
315
+ factorCount,
316
+ categories,
317
+ recoverabilityLevel,
318
+ recoverabilityLabel,
319
+ recoverabilityColor,
320
+ recoverabilityProgress,
321
+ recoveryMethods,
322
+ nudges,
323
+ };
324
+ }