@structured-id/ui-core 1.2.0 → 2.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 (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 +110 -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
@@ -1,15 +1,20 @@
1
1
  /**
2
2
  * Profile auth API composable.
3
3
  *
4
- * Wraps AuthServiceClient proto calls with Bearer auth.
5
4
  * Covers: OPAQUE, MFA, step-up, WebAuthn, magic link, OAuth2, CIBA, captcha, terms.
6
5
  *
7
- * OPAQUE gRPC calls are here (like all other auth RPCs).
8
- * OPAQUE client-side crypto orchestration lives in useOpaqueAuth.ts
9
- * (accepts OpaqueClientApi interface — no dependency on @structured-id/opaque).
6
+ * Two surfaces: sign-in ceremonies go to SID's sign-in surface
7
+ * (`getTransport`), where the IdP session lives; managing the signed-in
8
+ * user's factors from the account pages is the account API
9
+ * (`getAccountTransport`), through the BFF.
10
+ *
11
+ * OPAQUE gRPC calls are here (like all other auth RPCs). The password
12
+ * ceremonies over them (client-side crypto, proofs) live in useAuth.ts,
13
+ * behind the injected ZkppClientApi: no dependency on the client package.
10
14
  */
11
15
  import {
12
16
  getTransport,
17
+ getAccountTransport,
13
18
  AuthServiceClient,
14
19
  StepUpMethod,
15
20
  type OpaqueLoginStartResponse,
@@ -48,17 +53,43 @@ import {
48
53
  type RequestPasswordResetResponse,
49
54
  type VerifyPasswordResetResponse,
50
55
  type CompletePasswordResetResponse,
56
+ type ExecutePasswordResetResponse,
57
+ type OpaqueZkppRegistrationStartResponse,
58
+ type OpaqueZkppRegistrationFinishResponse,
59
+ type PasswordChangeChallengeResponse,
60
+ type PasswordChangeExecuteResponse,
61
+ type PasswordChangeFinishResponse,
62
+ type PasswordHistoryContext,
63
+ type PasswordHistoryEvaluation,
64
+ type PasswordRegistrationProof,
65
+ PasswordHistoryEvaluatorServiceClient,
51
66
  } from "../../index";
52
67
  import { authMeta } from "../auth";
53
68
 
69
+ /** The history evaluator: in CE served beside the credential service. */
70
+ function evaluator(): PasswordHistoryEvaluatorServiceClient {
71
+ return new PasswordHistoryEvaluatorServiceClient(getTransport());
72
+ }
73
+
74
+ /** The wire form of an operation id (16 UUIDv7 bytes). */
75
+ function operation(id: Uint8Array): { value: Uint8Array } {
76
+ return { value: id };
77
+ }
78
+
79
+ /** The sign-in surface: ceremonies and the IdP session. */
54
80
  function client(): AuthServiceClient {
55
81
  return new AuthServiceClient(getTransport());
56
82
  }
57
83
 
84
+ /** The account API: the signed-in user managing their own factors. */
85
+ function account(): AuthServiceClient {
86
+ return new AuthServiceClient(getAccountTransport());
87
+ }
88
+
58
89
  // ── MFA TOTP ──
59
90
 
60
91
  export async function startTotpEnrollment(): Promise<TotpEnrollmentChallenge> {
61
- const { response } = await client().startTotpEnrollment(
92
+ const { response } = await account().startTotpEnrollment(
62
93
  {},
63
94
  { meta: authMeta() },
64
95
  );
@@ -68,7 +99,7 @@ export async function startTotpEnrollment(): Promise<TotpEnrollmentChallenge> {
68
99
  export async function finishTotpEnrollment(
69
100
  code: string,
70
101
  ): Promise<FinishTotpEnrollmentResponse> {
71
- const { response } = await client().finishTotpEnrollment(
102
+ const { response } = await account().finishTotpEnrollment(
72
103
  { code },
73
104
  { meta: authMeta() },
74
105
  );
@@ -101,7 +132,7 @@ export async function resendSmsMfa(): Promise<ResendSmsMfaResponse> {
101
132
  // ── MFA Recovery ──
102
133
 
103
134
  export async function generateRecoveryCodes(): Promise<GenerateRecoveryCodesResponse> {
104
- const { response } = await client().generateRecoveryCodes(
135
+ const { response } = await account().generateRecoveryCodes(
105
136
  {},
106
137
  { meta: authMeta() },
107
138
  );
@@ -199,7 +230,7 @@ export async function webAuthnAuthenticationFinish(
199
230
  export async function webAuthnRegistrationStart(
200
231
  label?: string,
201
232
  ): Promise<WebAuthnRegistrationStartResponse> {
202
- const { response } = await client().webAuthnRegistrationStart(
233
+ const { response } = await account().webAuthnRegistrationStart(
203
234
  { label },
204
235
  { meta: authMeta() },
205
236
  );
@@ -210,7 +241,7 @@ export async function webAuthnRegistrationFinish(
210
241
  credential: Uint8Array,
211
242
  label?: string,
212
243
  ): Promise<WebAuthnRegistrationFinishResponse> {
213
- const { response } = await client().webAuthnRegistrationFinish(
244
+ const { response } = await account().webAuthnRegistrationFinish(
214
245
  { credential, label },
215
246
  { meta: authMeta() },
216
247
  );
@@ -241,6 +272,10 @@ export async function oauth2Authorize(params: {
241
272
  codeChallenge?: string;
242
273
  codeChallengeMethod?: string;
243
274
  nonce?: string;
275
+ /** Issuer of the authorization request being completed (`/i/<handle>/oauth2/authorize`). */
276
+ issuerHandle: string;
277
+ /** RFC 8707 resource indicators of the request; empty selects the client's default. */
278
+ resource?: string[];
244
279
  }): Promise<{ authorizationCode?: string; error?: string; state?: string }> {
245
280
  const { response } = await client().oAuth2Authorize(
246
281
  {
@@ -252,6 +287,8 @@ export async function oauth2Authorize(params: {
252
287
  codeChallenge: params.codeChallenge,
253
288
  codeChallengeMethod: params.codeChallengeMethod,
254
289
  nonce: params.nonce,
290
+ issuerHandle: params.issuerHandle,
291
+ resource: params.resource ?? [],
255
292
  },
256
293
  { meta: authMeta() },
257
294
  );
@@ -455,7 +492,7 @@ export async function completeStepUp(
455
492
  export async function deleteMfaCredential(
456
493
  credentialId: string,
457
494
  ): Promise<DeleteMfaCredentialResponse> {
458
- const { response } = await client().deleteMfaCredential(
495
+ const { response } = await account().deleteMfaCredential(
459
496
  { credentialId },
460
497
  { meta: authMeta() },
461
498
  );
@@ -502,23 +539,37 @@ export async function verifyPasswordReset(
502
539
  return response;
503
540
  }
504
541
 
542
+ /**
543
+ * The OPAQUE start of the replacement password under the reset's operation
544
+ * (from `VerifyPasswordResetResponse.history`).
545
+ */
546
+ export async function executePasswordReset(
547
+ operationId: Uint8Array,
548
+ registrationRequest: Uint8Array,
549
+ ): Promise<ExecutePasswordResetResponse> {
550
+ const { response } = await client().executePasswordReset({
551
+ operationId: operation(operationId),
552
+ registrationRequest,
553
+ });
554
+ return response;
555
+ }
556
+
557
+ /**
558
+ * Complete the reset with the replacement password's record and, when the
559
+ * client proved it, the proof; without a proof the password installs
560
+ * policy-unverified.
561
+ */
505
562
  export async function completePasswordReset(
506
563
  resetSessionId: string,
564
+ operationId: Uint8Array,
507
565
  registrationRecord: Uint8Array,
508
- opts?: {
509
- zkppProof?: Uint8Array;
510
- zkppInstances?: Uint8Array[];
511
- historyCommitment?: Uint8Array;
512
- commitmentSalt?: Uint8Array;
513
- },
566
+ proof?: PasswordRegistrationProof,
514
567
  ): Promise<CompletePasswordResetResponse> {
515
568
  const { response } = await client().completePasswordReset({
516
569
  resetSessionId,
570
+ operationId: operation(operationId),
517
571
  registrationRecord,
518
- zkppProof: opts?.zkppProof ?? new Uint8Array(),
519
- zkppInstances: opts?.zkppInstances ?? [],
520
- historyCommitment: opts?.historyCommitment ?? new Uint8Array(),
521
- commitmentSalt: opts?.commitmentSalt ?? new Uint8Array(),
572
+ proof,
522
573
  });
523
574
  return response;
524
575
  }
@@ -526,6 +577,107 @@ export async function completePasswordReset(
526
577
  // Recovery shard RPCs (downloadRecoveryShard, uploadRecoveryShard, getRecoveryInfo)
527
578
  // are EE/SaaS-only — defined in proto-ee, not in CE proto.
528
579
 
580
+ // ── Password operations (OPAQUE-ZKPP) ──
581
+ //
582
+ // Registration, password change and reset install a password in one
583
+ // server-side operation: the preparing RPC returns the operation's history
584
+ // context, the evaluator answers the client's blinded history input, the
585
+ // client proves, and the finish carries the record with the proof.
586
+
587
+ /**
588
+ * Start a registration: the operation is prepared for the new account and its
589
+ * OPAQUE request answered. `claimToken` claims an installation that has no
590
+ * administrator yet.
591
+ */
592
+ export async function opaqueZkppRegistrationStart(
593
+ principal: string,
594
+ registrationRequest: Uint8Array,
595
+ claimToken?: string,
596
+ ): Promise<OpaqueZkppRegistrationStartResponse> {
597
+ const { response } = await client().opaqueZkppRegistrationStart({
598
+ principal,
599
+ registrationRequest,
600
+ claimToken,
601
+ });
602
+ return response;
603
+ }
604
+
605
+ /** Finish a registration with the record and, when the client proved, the proof. */
606
+ export async function opaqueZkppRegistrationFinish(
607
+ operationId: Uint8Array,
608
+ registrationRecord: Uint8Array,
609
+ proof?: PasswordRegistrationProof,
610
+ ): Promise<OpaqueZkppRegistrationFinishResponse> {
611
+ const { response } = await client().opaqueZkppRegistrationFinish({
612
+ operationId: operation(operationId),
613
+ registrationRecord,
614
+ proof,
615
+ });
616
+ return response;
617
+ }
618
+
619
+ /**
620
+ * The evaluator's answers to the blinded history input of the operation:
621
+ * one per comparison domain of its context, in that order.
622
+ */
623
+ export async function evaluatePasswordHistory(
624
+ operationId: Uint8Array,
625
+ blindedInput: Uint8Array,
626
+ ): Promise<PasswordHistoryEvaluation[]> {
627
+ const { response } = await evaluator().evaluatePasswordHistory({
628
+ operationId: operation(operationId),
629
+ blindedInput,
630
+ });
631
+ return response.evaluations;
632
+ }
633
+
634
+ /** Prepare a change of the signed-in user's own password `credentialId`. */
635
+ export async function passwordChangeChallenge(
636
+ credentialId: string,
637
+ ): Promise<PasswordChangeChallengeResponse> {
638
+ const { response } = await account().passwordChangeChallenge(
639
+ { credentialId },
640
+ { meta: authMeta() },
641
+ );
642
+ return response;
643
+ }
644
+
645
+ /** The OPAQUE start of the new password under the change's operation. */
646
+ export async function passwordChangeExecute(
647
+ operationId: Uint8Array,
648
+ credentialId: string,
649
+ registrationRequest: Uint8Array,
650
+ ): Promise<PasswordChangeExecuteResponse> {
651
+ const { response } = await account().passwordChangeExecute(
652
+ {
653
+ operationId: operation(operationId),
654
+ credentialId,
655
+ registrationRequest,
656
+ },
657
+ { meta: authMeta() },
658
+ );
659
+ return response;
660
+ }
661
+
662
+ /** Finish the change with the new password's record and, when proved, the proof. */
663
+ export async function passwordChangeFinish(
664
+ operationId: Uint8Array,
665
+ credentialId: string,
666
+ registrationRecord: Uint8Array,
667
+ proof?: PasswordRegistrationProof,
668
+ ): Promise<PasswordChangeFinishResponse> {
669
+ const { response } = await account().passwordChangeFinish(
670
+ {
671
+ operationId: operation(operationId),
672
+ credentialId,
673
+ registrationRecord,
674
+ proof,
675
+ },
676
+ { meta: authMeta() },
677
+ );
678
+ return response;
679
+ }
680
+
529
681
  // ── OPAQUE ──
530
682
 
531
683
  export async function opaqueLoginStart(
@@ -552,13 +704,20 @@ export async function opaqueLoginFinish(
552
704
  return response;
553
705
  }
554
706
 
707
+ /**
708
+ * `claimToken`: the instance claim token (`sidclaim_...`) from the service log.
709
+ * Required while the installation has no administrator; the new profile then
710
+ * becomes its first administrator.
711
+ */
555
712
  export async function opaqueRegistrationStart(
556
713
  principal: string,
557
714
  registrationRequest: Uint8Array,
715
+ claimToken?: string,
558
716
  ): Promise<OpaqueRegistrationStartResponse> {
559
717
  const { response } = await client().opaqueRegistrationStart({
560
718
  principal,
561
719
  registrationRequest,
720
+ claimToken,
562
721
  });
563
722
  return response;
564
723
  }
@@ -613,5 +772,14 @@ export type {
613
772
  VerifyMagicLinkResponse,
614
773
  RequestPasswordResetResponse,
615
774
  VerifyPasswordResetResponse,
775
+ ExecutePasswordResetResponse,
616
776
  CompletePasswordResetResponse,
777
+ OpaqueZkppRegistrationStartResponse,
778
+ OpaqueZkppRegistrationFinishResponse,
779
+ PasswordChangeChallengeResponse,
780
+ PasswordChangeExecuteResponse,
781
+ PasswordChangeFinishResponse,
782
+ PasswordHistoryContext,
783
+ PasswordHistoryEvaluation,
784
+ PasswordRegistrationProof,
617
785
  };
@@ -0,0 +1,33 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { getInstanceStatus } from "./useEnrollmentApi";
3
+
4
+ const mockGetInstanceStatus = vi.fn();
5
+
6
+ vi.mock("../../index", () => ({
7
+ getTransport: vi.fn(),
8
+ EnrollmentServiceClient: vi.fn().mockImplementation(() => ({
9
+ getInstanceStatus: mockGetInstanceStatus,
10
+ })),
11
+ }));
12
+
13
+ describe("getInstanceStatus", () => {
14
+ beforeEach(() => {
15
+ vi.clearAllMocks();
16
+ });
17
+
18
+ // The registration page asks before anyone signs in, so the call must
19
+ // carry no credentials and return the server's answer as is.
20
+ it("asks without credentials and returns the status", async () => {
21
+ mockGetInstanceStatus.mockResolvedValue({ response: { claimed: false } });
22
+
23
+ const status = await getInstanceStatus();
24
+
25
+ expect(mockGetInstanceStatus).toHaveBeenCalledWith({});
26
+ expect(status).toEqual({ claimed: false });
27
+ });
28
+
29
+ it("propagates gRPC errors", async () => {
30
+ mockGetInstanceStatus.mockRejectedValue(new Error("UNAVAILABLE"));
31
+ await expect(getInstanceStatus()).rejects.toThrow("UNAVAILABLE");
32
+ });
33
+ });
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Profile enrollment API composable.
3
+ *
4
+ * Wraps the public EnrollmentServiceClient calls the registration page makes
5
+ * before anyone signs in.
6
+ */
7
+ import {
8
+ getTransport,
9
+ EnrollmentServiceClient,
10
+ type InstanceStatus,
11
+ } from "../../index";
12
+
13
+ function client(): EnrollmentServiceClient {
14
+ return new EnrollmentServiceClient(getTransport());
15
+ }
16
+
17
+ export type { InstanceStatus };
18
+
19
+ /**
20
+ * Whether the installation has its first administrator. While it has none,
21
+ * registration accepts only the claim token from the service log.
22
+ */
23
+ export async function getInstanceStatus(): Promise<InstanceStatus> {
24
+ const { response } = await client().getInstanceStatus({});
25
+ return response;
26
+ }
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * Profile identity API composable.
3
3
  *
4
- * Wraps IdentityServiceClient proto calls with Bearer auth.
5
- * Shared between profile-ui-ce, profile-ui-ee, and frontend.
4
+ * The account API through the BFF (`getAccountTransport`), which adds the
5
+ * session's Bearer token. Shared between profile-ui-ce, profile-ui-ee, and
6
+ * frontend.
6
7
  */
8
+ import type { RpcTransport } from "@protobuf-ts/runtime-rpc";
7
9
  import {
8
- getTransport,
10
+ getAccountTransport,
9
11
  IdentityServiceClient,
10
12
  type Profile,
11
13
  type Session,
@@ -19,8 +21,8 @@ import {
19
21
  } from "../../index";
20
22
  import { authMeta } from "../auth";
21
23
 
22
- function client(): IdentityServiceClient {
23
- return new IdentityServiceClient(getTransport());
24
+ function client(transport?: RpcTransport): IdentityServiceClient {
25
+ return new IdentityServiceClient(transport ?? getAccountTransport());
24
26
  }
25
27
 
26
28
  // ── Profile ──
@@ -28,10 +30,17 @@ function client(): IdentityServiceClient {
28
30
  export type { Profile, Principal };
29
31
 
30
32
  export async function fetchCurrentProfile(): Promise<Profile> {
31
- const { response } = await client().getCurrentProfile({}, { meta: authMeta() });
33
+ const { response } = await client().getCurrentProfile(
34
+ {},
35
+ { meta: authMeta() },
36
+ );
32
37
  return response.profile!;
33
38
  }
34
39
 
40
+ /**
41
+ * `transport`: the sign-in surface's transport when a sign-in step (a
42
+ * required profile update) makes the call; the account API otherwise.
43
+ */
35
44
  export async function updateProfile(
36
45
  id: string,
37
46
  update: {
@@ -40,8 +49,9 @@ export async function updateProfile(
40
49
  middleName?: string;
41
50
  avatarUrl?: string;
42
51
  },
52
+ transport?: RpcTransport,
43
53
  ): Promise<Profile> {
44
- const { response } = await client().updateProfile(
54
+ const { response } = await client(transport).updateProfile(
45
55
  {
46
56
  id,
47
57
  givenName: update.givenName,
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
2
2
  import { setActivePinia, createPinia } from "pinia";
3
3
  import { createApp, defineComponent } from "vue";
4
4
  import { createRouter, createMemoryHistory } from "vue-router";
5
- import { useLoginSuccess } from "./useLoginSuccess";
5
+ import { authorizationContinuation, useLoginSuccess } from "./useLoginSuccess";
6
6
  import { useAuthStore } from "../../stores/auth";
7
7
 
8
8
  // Mock checkBffSession from BFF composable
@@ -13,19 +13,22 @@ vi.mock("../../composables/useBffAuth", () => ({
13
13
  import { checkBffSession } from "../../composables/useBffAuth";
14
14
  const mockCheckBffSession = vi.mocked(checkBffSession);
15
15
 
16
- // Helper: run composable inside a real Vue app with router + pinia
16
+ // Helper: run composable inside a real Vue app with router + pinia, the
17
+ // current route at `at`.
17
18
  async function withApp<T>(
18
19
  fn: () => T,
20
+ at = "/",
19
21
  ): Promise<{ result: T; router: ReturnType<typeof createRouter> }> {
20
22
  const router = createRouter({
21
23
  history: createMemoryHistory(),
22
24
  routes: [
23
25
  { path: "/", component: { template: "<div />" } },
26
+ { path: "/login", component: { template: "<div />" } },
24
27
  { path: "/account", component: { template: "<div />" } },
25
28
  { path: "/admin", component: { template: "<div />" } },
26
29
  ],
27
30
  });
28
- await router.push("/");
31
+ await router.push(at);
29
32
  await router.isReady();
30
33
 
31
34
  const pinia = createPinia();
@@ -47,6 +50,50 @@ async function withApp<T>(
47
50
  return { result: result!, router };
48
51
  }
49
52
 
53
+ const ISSUER = "https://sid.example.com";
54
+ const HANDLE = "0123456789abcdef0123456789abcdef";
55
+ const CONTINUATION = `${ISSUER}/i/${HANDLE}/oauth2/authorize?client_id=app&request_uri=${encodeURIComponent("urn:ietf:params:oauth:request_uri:ref_1")}`;
56
+
57
+ describe("authorizationContinuation", () => {
58
+ it("accepts the continuation of a request at the issuer", () => {
59
+ expect(authorizationContinuation(CONTINUATION, ISSUER)).toBe(CONTINUATION);
60
+ // The origin may be given as any URL of the installation.
61
+ expect(authorizationContinuation(CONTINUATION, `${ISSUER}/account`)).toBe(
62
+ CONTINUATION,
63
+ );
64
+ });
65
+
66
+ // Each substitution an attacker could make in `rd` is refused: another
67
+ // origin, scheme or port, credentials, a fragment, another path, a full
68
+ // request instead of a reference, extra or missing parameters.
69
+ it("refuses everything else", () => {
70
+ const ref = encodeURIComponent("urn:ietf:params:oauth:request_uri:ref_1");
71
+ const at = (base: string) =>
72
+ `${base}/i/${HANDLE}/oauth2/authorize?client_id=app&request_uri=${ref}`;
73
+ for (const rd of [
74
+ undefined,
75
+ ["x"],
76
+ "",
77
+ "not a url",
78
+ "javascript:alert(1)",
79
+ at("https://evil.example.com"),
80
+ at("http://sid.example.com"),
81
+ at("https://sid.example.com:8443"),
82
+ at("https://user:pw@sid.example.com"),
83
+ `${CONTINUATION}#x`,
84
+ `${ISSUER}/i/${HANDLE}/oauth2/token?client_id=app&request_uri=${ref}`,
85
+ `${ISSUER}/i/NOTAHANDLE/oauth2/authorize?client_id=app&request_uri=${ref}`,
86
+ `${ISSUER}/i/${HANDLE}/oauth2/authorize?client_id=app&redirect_uri=https%3A%2F%2Fevil.example.com`,
87
+ `${CONTINUATION}&redirect_uri=https%3A%2F%2Fevil.example.com`,
88
+ `${ISSUER}/i/${HANDLE}/oauth2/authorize?client_id=app&request_uri=https%3A%2F%2Fevil`,
89
+ `${ISSUER}/i/${HANDLE}/oauth2/authorize?client_id=&request_uri=${ref}`,
90
+ ]) {
91
+ expect(authorizationContinuation(rd, ISSUER), String(rd)).toBeNull();
92
+ }
93
+ expect(authorizationContinuation(CONTINUATION, "not a url")).toBeNull();
94
+ });
95
+ });
96
+
50
97
  describe("useLoginSuccess (BFF cookie mode)", () => {
51
98
  beforeEach(() => {
52
99
  vi.clearAllMocks();
@@ -97,6 +144,53 @@ describe("useLoginSuccess (BFF cookie mode)", () => {
97
144
  expect(auth.isAuthenticated).toBe(false);
98
145
  });
99
146
 
147
+ // A sign-in an authorization request sent the user to returns the browser
148
+ // to that request's continuation, and does not touch the app's session.
149
+ it("returns to the authorization request it was sent from", async () => {
150
+ const leaveTo = vi.fn();
151
+ const { result, router } = await withApp(
152
+ () => useLoginSuccess("/account", { issuerOrigin: ISSUER, leaveTo }),
153
+ `/login?rd=${encodeURIComponent(CONTINUATION)}`,
154
+ );
155
+
156
+ await result.onLoginSuccess();
157
+
158
+ expect(leaveTo).toHaveBeenCalledWith(CONTINUATION);
159
+ expect(mockCheckBffSession).not.toHaveBeenCalled();
160
+ expect(router.currentRoute.value.path).toBe("/login");
161
+ });
162
+
163
+ // An `rd` that is not exactly a continuation at this installation's issuer
164
+ // is ignored: the user lands in the app, never at the given address.
165
+ it("ignores an rd it did not validate", async () => {
166
+ mockCheckBffSession.mockResolvedValue(null);
167
+ const leaveTo = vi.fn();
168
+ const { result, router } = await withApp(
169
+ () => useLoginSuccess("/account", { issuerOrigin: ISSUER, leaveTo }),
170
+ `/login?rd=${encodeURIComponent("https://evil.example.com/")}`,
171
+ );
172
+
173
+ await result.onLoginSuccess();
174
+
175
+ expect(leaveTo).not.toHaveBeenCalled();
176
+ expect(router.currentRoute.value.path).toBe("/account");
177
+ });
178
+
179
+ // Without an issuer origin no rd is followed at all.
180
+ it("follows no rd without an issuer origin", async () => {
181
+ mockCheckBffSession.mockResolvedValue(null);
182
+ const leaveTo = vi.fn();
183
+ const { result, router } = await withApp(
184
+ () => useLoginSuccess("/account", { leaveTo }),
185
+ `/login?rd=${encodeURIComponent(CONTINUATION)}`,
186
+ );
187
+
188
+ await result.onLoginSuccess();
189
+
190
+ expect(leaveTo).not.toHaveBeenCalled();
191
+ expect(router.currentRoute.value.path).toBe("/account");
192
+ });
193
+
100
194
  it("navigates when BFF session returns null (cookie not yet set)", async () => {
101
195
  mockCheckBffSession.mockResolvedValue(null);
102
196