@structured-id/ui-core 4.3.1 → 4.5.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@structured-id/ui-core",
3
- "version": "4.3.1",
3
+ "version": "4.5.0",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -42,7 +42,7 @@
42
42
  "@protobuf-ts/grpcweb-transport": "^2.11.1",
43
43
  "@protobuf-ts/runtime": "^2.11.1",
44
44
  "@protobuf-ts/runtime-rpc": "^2.11.1",
45
- "@structured-id/proto": "^0.3.0",
45
+ "@structured-id/proto": "^0.5.0",
46
46
  "@structured-world/structured-email-address-ts": "^0.0.22",
47
47
  "@structured-world/structured-public-domains": "0.0.x",
48
48
  "libphonenumber-js": "^1.13.15"
@@ -1,11 +1,4 @@
1
- export {
2
- useGrpc,
3
- initGrpc,
4
- getTransport,
5
- closeTransport,
6
- getTransportType,
7
- type TransportType,
8
- } from "./useGrpc";
1
+ export { useGrpc, initGrpc, getTransport, closeTransport } from "./useGrpc";
9
2
  export {
10
3
  initAccountApi,
11
4
  getAccountTransport,
@@ -1,8 +1,9 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
- import type {
3
- MethodInfo,
4
- RpcInterceptor,
5
- RpcOptions,
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import {
3
+ RpcError,
4
+ type MethodInfo,
5
+ type RpcInterceptor,
6
+ type RpcOptions,
6
7
  } from "@protobuf-ts/runtime-rpc";
7
8
 
8
9
  const created: Array<{
@@ -19,6 +20,19 @@ vi.mock("@protobuf-ts/grpcweb-transport", () => ({
19
20
  }),
20
21
  }));
21
22
 
23
+ /** What the UI host's session service answers; each test sets its calls. */
24
+ const host = vi.hoisted(() => ({
25
+ getApplicationSession: vi.fn(),
26
+ endApplicationSession: vi.fn(),
27
+ }));
28
+
29
+ vi.mock("@structured-id/proto/sid/v1/authn/application_session.client", () => ({
30
+ ApplicationSessionServiceClient: class {
31
+ getApplicationSession = host.getApplicationSession;
32
+ endApplicationSession = host.endApplicationSession;
33
+ },
34
+ }));
35
+
22
36
  import {
23
37
  initAccountApi,
24
38
  getAccountTransport,
@@ -26,9 +40,14 @@ import {
26
40
  } from "./useAccountTransport";
27
41
  import { bffLogout, checkBffSession, initBff } from "./useBffAuth";
28
42
 
43
+ /** The account transport's options: the last transport created. */
44
+ function account() {
45
+ return created[created.length - 1];
46
+ }
47
+
29
48
  /** Run the transport's interceptor over a call and return the options it passed on. */
30
49
  function intercepted(options: RpcOptions): RpcOptions {
31
- const [interceptor] = created[created.length - 1].interceptors ?? [];
50
+ const [interceptor] = account().interceptors ?? [];
32
51
  let passed: RpcOptions | undefined;
33
52
  interceptor.interceptUnary!(
34
53
  (_method, _input, opts) => {
@@ -42,68 +61,57 @@ function intercepted(options: RpcOptions): RpcOptions {
42
61
  return passed!;
43
62
  }
44
63
 
45
- /** The BFF's answer to the page's session check: the user with the session's token. */
46
- function bffSession(csrfToken: string) {
47
- vi.stubGlobal(
48
- "fetch",
49
- vi.fn().mockResolvedValue({
50
- ok: true,
51
- status: 200,
52
- json: async () => ({ sub: "up_1", csrf_token: csrfToken }),
53
- }),
54
- );
55
- }
56
-
57
- /** The BFF's answer without a session. */
58
- function noBffSession() {
59
- vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 401 }));
64
+ /** The host's answer to the page's session read: a session with this token. */
65
+ async function session(csrfToken: string) {
66
+ host.getApplicationSession.mockResolvedValue({
67
+ response: { sub: "up_1", acr: "", csrfToken },
68
+ });
69
+ await checkBffSession();
60
70
  }
61
71
 
62
72
  describe("account API transport", () => {
63
73
  beforeEach(async () => {
64
74
  created.length = 0;
75
+ vi.clearAllMocks();
65
76
  closeAccountTransport();
66
77
  initBff("");
67
- noBffSession();
78
+ host.getApplicationSession.mockRejectedValue(
79
+ new RpcError("no session", "UNAUTHENTICATED"),
80
+ );
68
81
  await checkBffSession();
69
82
  });
70
83
 
71
- afterEach(() => {
72
- vi.unstubAllGlobals();
84
+ // The account API is reached only through the UI host on the page's own
85
+ // origin: its cookie rides along same-origin, and no other origin gets it.
86
+ it("goes to the UI host on the page's origin with same-origin credentials", () => {
87
+ initAccountApi({ baseUrl: "" });
88
+ expect(account().baseUrl).toBe("");
89
+ expect(account().fetchInit).toEqual({ credentials: "same-origin" });
73
90
  });
74
91
 
75
- // The account API is reached only through the BFF on the page's own origin:
76
- // its cookie rides along same-origin, and no other origin gets it.
77
- it("goes to the BFF's API route with same-origin credentials", () => {
78
- initAccountApi({ baseUrl: "/api" });
79
- expect(created[0].baseUrl).toBe("/api");
80
- expect(created[0].fetchInit).toEqual({ credentials: "same-origin" });
81
- });
82
-
83
- // Every call echoes the session's CSRF token, which the BFF hands the page
84
- // with the session check: the BFF refuses a call without it, which is what
92
+ // Every call echoes the session's CSRF token, which the host hands the page
93
+ // with the session read: the host refuses a call without it, which is what
85
94
  // stops a foreign page driving the account API.
86
- it("sends the CSRF token the BFF gave with the session on every call", async () => {
87
- initAccountApi({ baseUrl: "/api" });
88
- bffSession("token-1");
89
- await checkBffSession();
95
+ it("sends the session's CSRF token on every call", async () => {
96
+ initAccountApi({ baseUrl: "" });
97
+ await session("token-1");
90
98
  const options = intercepted({ meta: { other: "kept" } });
91
99
  expect(options.meta).toEqual({ other: "kept", "x-csrf-token": "token-1" });
92
100
  });
93
101
 
94
- // No session, no header: the call goes out and the BFF answers it (401 or
95
- // 403), rather than the page inventing a token.
102
+ // No session, no header: the call goes out and the host answers it, rather
103
+ // than the page inventing a token.
96
104
  it("sends no CSRF header without a session", () => {
97
- initAccountApi({ baseUrl: "/api" });
105
+ initAccountApi({ baseUrl: "" });
98
106
  const options = intercepted({ meta: {} });
99
107
  expect(options.meta).toEqual({});
100
108
  });
101
109
 
102
110
  // Sign-out ends the session; its token goes with it.
103
111
  it("forgets the token at sign-out", async () => {
104
- initAccountApi({ baseUrl: "/api" });
105
- bffSession("token-1");
106
- await checkBffSession();
112
+ initAccountApi({ baseUrl: "" });
113
+ await session("token-1");
114
+ host.endApplicationSession.mockResolvedValue({ response: { grant: 1 } });
107
115
  await bffLogout();
108
116
  const options = intercepted({ meta: {} });
109
117
  expect(options.meta).toEqual({});
@@ -116,7 +124,7 @@ describe("account API transport", () => {
116
124
  });
117
125
 
118
126
  it("is the one set up", () => {
119
- initAccountApi({ baseUrl: "/api" });
127
+ initAccountApi({ baseUrl: "" });
120
128
  expect(getAccountTransport()).toEqual({ type: "grpc-web-transport" });
121
129
  });
122
130
  });
@@ -1,8 +1,8 @@
1
1
  /**
2
- * The account API transport: gRPC-Web through the BFF (sid-auth-proxy) on the
3
- * page's own origin. The BFF holds the session's tokens and adds the Bearer
4
- * token itself; the page sends only its session cookie and, on every call,
5
- * the CSRF token the BFF set (double submit).
2
+ * The account API transport: gRPC-Web through the application's UI host on
3
+ * the page's own origin. The host holds the session's tokens and adds the
4
+ * Bearer token itself; the page sends only its session cookie and, on every
5
+ * call, the session's CSRF token.
6
6
  *
7
7
  * Sign-in ceremonies are not account API calls: they go to SID's own sign-in
8
8
  * surface through `getTransport()`, where the IdP's session lives.
@@ -13,7 +13,7 @@ import { bffCsrfToken } from "./useBffAuth";
13
13
 
14
14
  /** Where the account API is reached. */
15
15
  export interface AccountApiConfig {
16
- /** The BFF's API route, usually `/api` on the page's own origin. */
16
+ /** The UI host's base URL: "" for the page's own origin, where it usually is. */
17
17
  baseUrl: string;
18
18
  }
19
19
 
@@ -1,4 +1,20 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { RpcError } from "@protobuf-ts/runtime-rpc";
3
+ import { GrantOutcome } from "@structured-id/proto/sid/v1/authn/application_session";
4
+
5
+ /** What the UI host's session service answers; each test sets its calls. */
6
+ const host = vi.hoisted(() => ({
7
+ getApplicationSession: vi.fn(),
8
+ endApplicationSession: vi.fn(),
9
+ }));
10
+
11
+ vi.mock("@structured-id/proto/sid/v1/authn/application_session.client", () => ({
12
+ ApplicationSessionServiceClient: class {
13
+ getApplicationSession = host.getApplicationSession;
14
+ endApplicationSession = host.endApplicationSession;
15
+ },
16
+ }));
17
+
2
18
  import {
3
19
  bffCsrfToken,
4
20
  bffLogout,
@@ -6,90 +22,125 @@ import {
6
22
  initBff,
7
23
  } from "./useBffAuth";
8
24
 
9
- /** A BFF that answers every request with this response. */
10
- function bffAnswers(response: unknown) {
11
- const fetchMock = vi.fn().mockResolvedValue(response);
12
- vi.stubGlobal("fetch", fetchMock);
13
- return fetchMock;
25
+ /** A unary call that answers `response`. */
26
+ function answers(response: unknown) {
27
+ return Promise.resolve({ response });
14
28
  }
15
29
 
16
- /** The BFF's sign-out answer: the session ended, with this grant outcome. */
17
- function ended(grant: string) {
18
- return { ok: true, status: 200, json: async () => ({ grant }) };
30
+ /** A unary call refused with gRPC status `code`. */
31
+ function refused(code: string) {
32
+ return Promise.reject(new RpcError("refused", code));
19
33
  }
20
34
 
21
- describe("bffLogout", () => {
22
- beforeEach(async () => {
35
+ /** A live session with CSRF token `token-1`. */
36
+ const LIVE = {
37
+ sub: "0192f3a4-7c1e-7b2a-9d4e-3f5a6b7c8d9e",
38
+ email: "user@sid.example.com",
39
+ acr: "urn:sid:acr:basic",
40
+ csrfToken: "token-1",
41
+ };
42
+
43
+ describe("checkBffSession", () => {
44
+ beforeEach(() => {
45
+ vi.clearAllMocks();
23
46
  initBff("");
24
- bffAnswers({
25
- ok: true,
26
- status: 200,
27
- json: async () => ({ sub: "s", csrf_token: "token-1" }),
47
+ });
48
+
49
+ // The page shows the signed-in user and keeps the session's CSRF token for
50
+ // the state-changing calls that follow.
51
+ it("reads the live session and keeps its CSRF token", async () => {
52
+ host.getApplicationSession.mockReturnValue(answers(LIVE));
53
+ await expect(checkBffSession()).resolves.toEqual({
54
+ sub: LIVE.sub,
55
+ email: LIVE.email,
56
+ name: null,
57
+ preferred_username: null,
58
+ acr: LIVE.acr,
59
+ csrf_token: "token-1",
28
60
  });
61
+ expect(bffCsrfToken()).toBe("token-1");
62
+ });
63
+
64
+ // No session is a signed-out page, and no token is kept from before.
65
+ it("reports a signed-out page as null and forgets the token", async () => {
66
+ host.getApplicationSession.mockReturnValue(answers(LIVE));
29
67
  await checkBffSession();
68
+ host.getApplicationSession.mockReturnValue(refused("UNAUTHENTICATED"));
69
+ await expect(checkBffSession()).resolves.toBeNull();
70
+ expect(bffCsrfToken()).toBeNull();
30
71
  });
31
72
 
32
- afterEach(() => {
33
- vi.unstubAllGlobals();
73
+ // An outage must not send a signed-in user to sign-in again.
74
+ it.each(["UNAVAILABLE", "INTERNAL"])(
75
+ "throws on %s instead of reporting a signed-out page",
76
+ async (code) => {
77
+ host.getApplicationSession.mockReturnValue(refused(code));
78
+ await expect(checkBffSession()).rejects.toBeInstanceOf(RpcError);
79
+ },
80
+ );
81
+
82
+ it("reports nothing before the session is set up", async () => {
83
+ vi.resetModules();
84
+ const fresh = await import("./useBffAuth");
85
+ await expect(fresh.checkBffSession()).resolves.toBeNull();
86
+ expect(host.getApplicationSession).not.toHaveBeenCalled();
87
+ });
88
+ });
89
+
90
+ describe("bffLogout", () => {
91
+ beforeEach(async () => {
92
+ vi.clearAllMocks();
93
+ initBff("");
94
+ host.getApplicationSession.mockReturnValue(answers(LIVE));
95
+ await checkBffSession();
34
96
  });
35
97
 
36
98
  // The page tells a local end of its session from one where the issuer also
37
99
  // revoked the grant, so it can say which happened.
38
- it.each(["revoked", "not_revoked", "none"] as const)(
100
+ it.each([
101
+ [GrantOutcome.REVOKED, "revoked"],
102
+ [GrantOutcome.NOT_REVOKED, "not_revoked"],
103
+ [GrantOutcome.NONE, "none"],
104
+ [GrantOutcome.UNKNOWN, "unknown"],
105
+ ] as const)(
39
106
  "reports the ended session with grant %s",
40
- async (grant) => {
41
- const fetchMock = bffAnswers(ended(grant));
42
- await expect(bffLogout()).resolves.toEqual({ ended: true, grant });
43
- expect(fetchMock).toHaveBeenCalledWith("/auth/logout", {
44
- method: "POST",
45
- credentials: "include",
46
- headers: { "X-CSRF-Token": "token-1" },
47
- });
107
+ async (grant, name) => {
108
+ host.endApplicationSession.mockReturnValue(answers({ grant }));
109
+ await expect(bffLogout()).resolves.toEqual({ ended: true, grant: name });
110
+ expect(host.endApplicationSession).toHaveBeenCalledWith(
111
+ {},
112
+ { meta: { "x-csrf-token": "token-1" } },
113
+ );
48
114
  },
49
115
  );
50
116
 
51
- // A refused or failed sign-out leaves the BFF session alive: the page must
52
- // not report it as ended.
53
- it.each([
54
- ["refused", { ok: false, status: 403 }],
55
- ["unavailable", { ok: false, status: 503 }],
56
- ])("reports a %s sign-out as not ended", async (_name, response) => {
57
- bffAnswers(response);
58
- await expect(bffLogout()).resolves.toEqual({ ended: false });
59
- });
117
+ // A refused or failed sign-out leaves the session alive: the page must not
118
+ // report it as ended.
119
+ it.each(["PERMISSION_DENIED", "UNAVAILABLE", "UNAUTHENTICATED"])(
120
+ "reports a sign-out refused with %s as not ended",
121
+ async (code) => {
122
+ host.endApplicationSession.mockReturnValue(refused(code));
123
+ await expect(bffLogout()).resolves.toEqual({ ended: false });
124
+ },
125
+ );
60
126
 
61
- it("reports an unreachable BFF as not ended", async () => {
62
- vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("offline")));
127
+ it("reports an unreachable host as not ended", async () => {
128
+ host.endApplicationSession.mockReturnValue(
129
+ Promise.reject(new TypeError("offline")),
130
+ );
63
131
  await expect(bffLogout()).resolves.toEqual({ ended: false });
64
132
  });
65
133
 
66
- // An answer the page cannot read still came after the BFF ended the
67
- // session; only the grant's fate is unknown.
68
- it("reports an unreadable answer as ended with an unknown grant", async () => {
69
- bffAnswers({
70
- ok: true,
71
- status: 200,
72
- json: async () => {
73
- throw new SyntaxError("not json");
74
- },
75
- });
76
- await expect(bffLogout()).resolves.toEqual({
77
- ended: true,
78
- grant: "unknown",
79
- });
80
- });
81
-
82
- it("reports nothing ended before the BFF is set up", async () => {
134
+ it("reports nothing ended before the session is set up", async () => {
83
135
  vi.resetModules();
84
136
  const fresh = await import("./useBffAuth");
85
- const fetchMock = bffAnswers(ended("revoked"));
86
137
  await expect(fresh.bffLogout()).resolves.toEqual({ ended: false });
87
- expect(fetchMock).not.toHaveBeenCalled();
138
+ expect(host.endApplicationSession).not.toHaveBeenCalled();
88
139
  });
89
140
 
90
- // Whatever the BFF answers, the page forgets the session's token.
141
+ // Whatever the host answers, the page forgets the session's token.
91
142
  it("forgets the CSRF token even when the sign-out failed", async () => {
92
- bffAnswers({ ok: false, status: 503 });
143
+ host.endApplicationSession.mockReturnValue(refused("UNAVAILABLE"));
93
144
  await bffLogout();
94
145
  expect(bffCsrfToken()).toBeNull();
95
146
  });
@@ -1,107 +1,126 @@
1
1
  /**
2
- * BFF (Backend-For-Frontend) session management composable.
2
+ * The page's application session.
3
3
  *
4
- * The tokens live in sid-auth-proxy's BFF session; the page holds only the
5
- * BFF's httpOnly session cookie. gRPC calls go through the BFF, which adds
6
- * the Bearer token itself, so the page never sees an access token. The
7
- * session's CSRF token comes with the session check and every state-changing
8
- * call echoes it in `X-CSRF-Token`.
4
+ * The tokens live in the application's UI host; the page holds only the
5
+ * host's httpOnly session cookie. The page reads its session and signs out
6
+ * through `sid.v1.authn.ApplicationSessionService` over gRPC-Web on its own
7
+ * origin, so the cookie travels with the same-origin request. The session's
8
+ * CSRF token comes with the session read and every state-changing call echoes
9
+ * it in `x-csrf-token`.
9
10
  *
10
11
  * Usage:
11
- * 1. Call `initBff(bffBaseUrl)` at app boot
12
- * 2. Call `checkBffSession()` to verify the session + populate the auth store
13
- * 3. Call `startBffLogin()` to redirect the user to the PKCE auth flow
14
- * 4. Call `bffLogout()` to clear the session
12
+ * 1. Call `initBff(baseUrl)` at app boot ("" for the page's own origin)
13
+ * 2. Call `checkBffSession()` to read the session and populate the auth store
14
+ * 3. Call `startBffLogin()` to send the browser to sign-in
15
+ * 4. Call `bffLogout()` to end the session
15
16
  */
17
+ import { GrpcWebFetchTransport } from "@protobuf-ts/grpcweb-transport";
18
+ import { RpcError, type RpcTransport } from "@protobuf-ts/runtime-rpc";
19
+ import { ApplicationSessionServiceClient } from "@structured-id/proto/sid/v1/authn/application_session.client";
20
+ import { GrantOutcome } from "@structured-id/proto/sid/v1/authn/application_session";
16
21
 
17
- /** User info returned from BFF /auth/userinfo endpoint. */
22
+ /** The signed-in user of the page's session. */
18
23
  export interface BffUserInfo {
19
24
  /** Opaque subject identifier; its form depends on the issuer, so it is never parsed as a profile id. */
20
25
  sub: string;
21
- /** Email address (if available in session claims). */
26
+ /** Email address, when disclosed to this application. */
22
27
  email?: string | null;
23
- /** Display name (full name from profile). */
28
+ /** Display name, when disclosed. */
24
29
  name?: string | null;
25
- /** Username or email — preferred identifier for display. */
30
+ /** Username, when disclosed. */
26
31
  preferred_username?: string | null;
27
- /** Group memberships. */
28
- groups?: string[] | null;
29
- /** Authentication Context Class Reference (acr_values). */
32
+ /** Authentication Context Class Reference of the sign-in. */
30
33
  acr?: string | null;
31
- /** Role assignments. */
32
- roles?: string[] | null;
33
- /** The session's CSRF token, echoed in `X-CSRF-Token` by every state-changing call. */
34
+ /** The session's CSRF token, echoed in `x-csrf-token` by every state-changing call. */
34
35
  csrf_token?: string | null;
35
36
  }
36
37
 
37
38
  let _bffBaseUrl: string | null = null;
39
+ let _session: ApplicationSessionServiceClient | null = null;
38
40
  /**
39
- * The CSRF token of the session the page last checked. Browser-only state:
40
- * the session check runs in the browser, never during server rendering.
41
+ * The CSRF token of the session the page last read. Browser-only state:
42
+ * the session read runs in the browser, never during server rendering.
41
43
  */
42
44
  let _csrfToken: string | null = null;
43
45
 
46
+ /** The session service at `baseUrl`; the cookie never travels to another origin. */
47
+ function sessionClient(baseUrl: string): ApplicationSessionServiceClient {
48
+ const transport: RpcTransport = new GrpcWebFetchTransport({
49
+ baseUrl,
50
+ fetchInit: { credentials: "same-origin" },
51
+ });
52
+ return new ApplicationSessionServiceClient(transport);
53
+ }
54
+
44
55
  /**
45
- * Initialize BFF configuration. Call once at app boot.
56
+ * Initialize the session's UI host. Call once at app boot.
46
57
  *
47
- * @param baseUrl BFF base URL: "" for the page's own origin, where the BFF usually is
58
+ * @param baseUrl The UI host's base URL: "" for the page's own origin, where it usually is
48
59
  */
49
60
  export function initBff(baseUrl: string): void {
50
61
  _bffBaseUrl = baseUrl;
62
+ _session = sessionClient(baseUrl);
51
63
  }
52
64
 
53
- /** Get the configured BFF base URL, or null if not initialized. */
65
+ /** Get the configured base URL, or null if not initialized. */
54
66
  export function getBffBaseUrl(): string | null {
55
67
  return _bffBaseUrl;
56
68
  }
57
69
 
58
70
  /**
59
- * The CSRF token the BFF gave with the session check, or null without a
60
- * checked session. The BFF refuses a state-changing call without it.
71
+ * The CSRF token the UI host gave with the session read, or null without a
72
+ * read session. The host refuses a state-changing call without it.
61
73
  */
62
74
  export function bffCsrfToken(): string | null {
63
75
  return _csrfToken;
64
76
  }
65
77
 
66
78
  /**
67
- * Check if the BFF session is valid by calling GET /auth/userinfo.
79
+ * Read the page's session with `GetApplicationSession`.
68
80
  *
69
- * Returns user info if session is active, null if unauthenticated, and
70
- * keeps the session's CSRF token for the calls that follow.
71
- * The browser automatically sends the httpOnly session cookie.
72
- * An empty base URL is the page's own origin, where the BFF usually is.
81
+ * Returns the user when the session is live and keeps its CSRF token for
82
+ * the calls that follow; null when the page is signed out (UNAUTHENTICATED)
83
+ * or the session was not set up. Any other failure (an unavailable session
84
+ * store, an unreachable host) is thrown: an outage never reads as a
85
+ * signed-out user.
73
86
  */
74
87
  export async function checkBffSession(
75
88
  bffBaseUrl?: string,
76
89
  ): Promise<BffUserInfo | null> {
77
- const base = bffBaseUrl ?? _bffBaseUrl;
78
- if (base === null) return null;
90
+ const client =
91
+ bffBaseUrl !== undefined ? sessionClient(bffBaseUrl) : _session;
92
+ if (client === null) return null;
79
93
  try {
80
- const resp = await fetch(`${base}/auth/userinfo`, {
81
- credentials: "include",
82
- });
83
- if (!resp.ok) {
94
+ const { response } = await client.getApplicationSession({});
95
+ _csrfToken = response.csrfToken || null;
96
+ return {
97
+ sub: response.sub,
98
+ email: response.email ?? null,
99
+ name: response.name ?? null,
100
+ preferred_username: response.preferredUsername ?? null,
101
+ acr: response.acr || null,
102
+ csrf_token: _csrfToken,
103
+ };
104
+ } catch (err) {
105
+ if (err instanceof RpcError && err.code === "UNAUTHENTICATED") {
84
106
  _csrfToken = null;
85
107
  return null;
86
108
  }
87
- const info = (await resp.json()) as BffUserInfo;
88
- _csrfToken = info.csrf_token ?? null;
89
- return info;
90
- } catch {
91
- return null;
109
+ throw err;
92
110
  }
93
111
  }
94
112
 
95
113
  /**
96
- * Redirect user to BFF login (PKCE flow initiation).
114
+ * Send the browser to sign-in (`StartSignIn`, a navigation to
115
+ * `/auth/login`).
97
116
  *
98
- * After successful login, BFF sets httpOnly session cookie and redirects
99
- * back to the SPA. The SPA should call checkBffSession() on load to restore state.
117
+ * After sign-in the UI host sets its httpOnly session cookie and returns to
118
+ * the page, which calls `checkBffSession()` on load.
100
119
  *
101
- * @param bffBaseUrl Override BFF base URL (uses configured URL if not provided)
102
- * @param postLoginRedirect Path on the BFF's origin to return to after
103
- * sign-in (`/account/security?tab=keys`), sent as the BFF's `rd`
104
- * parameter; the BFF returns anything else to its root
120
+ * @param bffBaseUrl Override the base URL (uses the configured one if not provided)
121
+ * @param postLoginRedirect Local path to return to after sign-in
122
+ * (`/account/security?tab=keys`), sent as `rd`; the host returns anything
123
+ * else to its root
105
124
  */
106
125
  export function startBffLogin(
107
126
  bffBaseUrl?: string,
@@ -109,7 +128,7 @@ export function startBffLogin(
109
128
  ): void {
110
129
  const base = bffBaseUrl ?? _bffBaseUrl;
111
130
  if (base === null) {
112
- console.warn("[BFF] Not initialized. Call initBff() at app boot.");
131
+ console.warn("[session] Not initialized. Call initBff() at app boot.");
113
132
  return;
114
133
  }
115
134
  const url = postLoginRedirect
@@ -119,59 +138,53 @@ export function startBffLogin(
119
138
  }
120
139
 
121
140
  /**
122
- * What became of the grant behind an ended BFF session: revoked by the
123
- * issuer, left to expire because the issuer did not revoke it, none held,
124
- * or unknown when the BFF's answer could not be read.
141
+ * What became of the grant behind an ended session: revoked by the issuer,
142
+ * left to expire because the issuer did not revoke it, none held, or
143
+ * unknown.
125
144
  */
126
145
  export type BffGrantOutcome = "revoked" | "not_revoked" | "none" | "unknown";
127
146
 
128
- /** The outcome of a sign-out: whether the BFF ended the session, and its grant. */
147
+ /** The outcome of a sign-out: whether the session ended, and its grant. */
129
148
  export type BffLogoutResult =
130
149
  { ended: true; grant: BffGrantOutcome } | { ended: false };
131
150
 
132
- const GRANT_OUTCOMES: readonly BffGrantOutcome[] = [
133
- "revoked",
134
- "not_revoked",
135
- "none",
136
- ];
151
+ const GRANT_OUTCOMES: Readonly<Partial<Record<GrantOutcome, BffGrantOutcome>>> =
152
+ {
153
+ [GrantOutcome.REVOKED]: "revoked",
154
+ [GrantOutcome.NOT_REVOKED]: "not_revoked",
155
+ [GrantOutcome.NONE]: "none",
156
+ };
137
157
 
138
158
  /**
139
- * Log out by calling POST /auth/logout with X-CSRF-Token header.
159
+ * End the session with `EndApplicationSession`, carrying the session's CSRF
160
+ * token in `x-csrf-token`.
140
161
  *
141
- * The BFF validates the CSRF header, ends the session, clears its cookie and
142
- * says whether the issuer revoked the grant. `ended: false` means the
143
- * session may still be alive (refused, unavailable, unreachable). The page
144
- * forgets the CSRF token either way.
162
+ * The host checks the token, ends the session, clears its cookie and says
163
+ * whether the issuer revoked the grant. `ended: false` means the session may
164
+ * still be alive (refused, unavailable, unreachable). The page forgets the
165
+ * CSRF token either way.
145
166
  *
146
- * @param bffBaseUrl Override BFF base URL
167
+ * @param bffBaseUrl Override the base URL
147
168
  */
148
169
  export async function bffLogout(bffBaseUrl?: string): Promise<BffLogoutResult> {
149
- const base = bffBaseUrl ?? _bffBaseUrl;
150
- if (base === null) return { ended: false };
170
+ const client =
171
+ bffBaseUrl !== undefined ? sessionClient(bffBaseUrl) : _session;
172
+ if (client === null) return { ended: false };
151
173
  const csrf = _csrfToken;
152
174
  _csrfToken = null;
153
- let resp: Response;
154
175
  try {
155
- resp = await fetch(`${base}/auth/logout`, {
156
- method: "POST",
157
- credentials: "include",
158
- headers: csrf ? { "X-CSRF-Token": csrf } : {},
159
- });
176
+ const { response } = await client.endApplicationSession(
177
+ {},
178
+ { meta: csrf ? { "x-csrf-token": csrf } : {} },
179
+ );
180
+ return { ended: true, grant: GRANT_OUTCOMES[response.grant] ?? "unknown" };
160
181
  } catch {
161
182
  return { ended: false };
162
183
  }
163
- if (!resp.ok) return { ended: false };
164
- try {
165
- const { grant } = (await resp.json()) as { grant?: unknown };
166
- const known = GRANT_OUTCOMES.find((outcome) => outcome === grant);
167
- return { ended: true, grant: known ?? "unknown" };
168
- } catch {
169
- return { ended: true, grant: "unknown" };
170
- }
171
184
  }
172
185
 
173
186
  /**
174
- * Vue composable for BFF auth actions.
187
+ * Vue composable for the session actions.
175
188
  */
176
189
  export function useBffAuth() {
177
190
  return {
@@ -4,28 +4,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
4
4
  // Mock modules BEFORE importing the module under test
5
5
  // ---------------------------------------------------------------------------
6
6
 
7
- const mockAdaptiveInit = vi
8
- .fn<() => Promise<void>>()
9
- .mockResolvedValue(undefined);
10
- const mockAdaptiveClose = vi.fn();
11
- const mockAdaptiveActiveTransport = vi
12
- .fn<() => string>()
13
- .mockReturnValue("grpc-web");
14
-
15
- vi.mock("@structured-id/proto/transport", () => ({
16
- // Constructor mocks are `function`s: they are called with `new`.
17
- AdaptiveRpcTransport: vi.fn().mockImplementation(function () {
18
- return {
19
- init: mockAdaptiveInit,
20
- close: mockAdaptiveClose,
21
- get activeTransport() {
22
- return mockAdaptiveActiveTransport();
23
- },
24
- };
25
- }),
26
- }));
27
-
28
7
  vi.mock("@protobuf-ts/grpcweb-transport", () => ({
8
+ // Constructor mocks are `function`s: they are called with `new`.
29
9
  GrpcWebFetchTransport: vi.fn().mockImplementation(function () {
30
10
  return { type: "grpc-web-transport" };
31
11
  }),
@@ -33,14 +13,7 @@ vi.mock("@protobuf-ts/grpcweb-transport", () => ({
33
13
 
34
14
  // Import AFTER mocks
35
15
  import { GrpcWebFetchTransport } from "@protobuf-ts/grpcweb-transport";
36
- import { AdaptiveRpcTransport } from "@structured-id/proto/transport";
37
- import {
38
- initGrpc,
39
- getTransport,
40
- closeTransport,
41
- getTransportType,
42
- useGrpc,
43
- } from "./useGrpc";
16
+ import { initGrpc, getTransport, closeTransport, useGrpc } from "./useGrpc";
44
17
  import type { GrpcConfig } from "../types/auth";
45
18
 
46
19
  // ---------------------------------------------------------------------------
@@ -50,11 +23,12 @@ import type { GrpcConfig } from "../types/auth";
50
23
  describe("useGrpc", () => {
51
24
  beforeEach(() => {
52
25
  vi.clearAllMocks();
53
- // Reset transport state by calling close between tests
54
26
  closeTransport();
55
27
  });
56
28
 
57
- describe("initGrpc — without WebTransport URL", () => {
29
+ describe("initGrpc", () => {
30
+ // The BFF session cookie rides on every call, so credentials are always
31
+ // included; nothing else configures the transport.
58
32
  it("creates GrpcWebFetchTransport with baseUrl and credentials", () => {
59
33
  const config: GrpcConfig = { baseUrl: "https://api.sid.example.com" };
60
34
  initGrpc(config);
@@ -63,75 +37,6 @@ describe("useGrpc", () => {
63
37
  baseUrl: "https://api.sid.example.com",
64
38
  fetchInit: { credentials: "include" },
65
39
  });
66
- expect(AdaptiveRpcTransport).not.toHaveBeenCalled();
67
- });
68
-
69
- it("getTransportType returns grpc-web", () => {
70
- initGrpc({ baseUrl: "https://api.sid.example.com" });
71
- expect(getTransportType()).toBe("grpc-web");
72
- });
73
- });
74
-
75
- describe("initGrpc — with WebTransport URL", () => {
76
- it("creates AdaptiveRpcTransport with fetchInit credentials", () => {
77
- const config: GrpcConfig = {
78
- baseUrl: "https://api.sid.example.com",
79
- webTransportUrl: "https://wt.api.sid.example.com",
80
- };
81
- initGrpc(config);
82
-
83
- expect(AdaptiveRpcTransport).toHaveBeenCalledWith(
84
- expect.objectContaining({
85
- grpcWebUrl: "https://api.sid.example.com",
86
- webTransportUrl: "https://wt.api.sid.example.com",
87
- fetchInit: { credentials: "include" },
88
- }),
89
- );
90
- expect(GrpcWebFetchTransport).not.toHaveBeenCalled();
91
- });
92
-
93
- it("calls adaptive.init() non-blocking on creation", async () => {
94
- initGrpc({
95
- baseUrl: "https://api.sid.example.com",
96
- webTransportUrl: "https://wt.api.sid.example.com",
97
- });
98
-
99
- // init() should be called (non-blocking fire-and-forget)
100
- // Give microtasks time to run
101
- await Promise.resolve();
102
- expect(mockAdaptiveInit).toHaveBeenCalled();
103
- });
104
-
105
- it("passes all WebTransport options to AdaptiveRpcTransport", () => {
106
- const onChange = vi.fn();
107
- initGrpc({
108
- baseUrl: "https://api.sid.example.com",
109
- webTransportUrl: "https://wt.api.sid.example.com",
110
- webTransportCertHash: "abcdef1234567890",
111
- webTransportReconnectDelay: 3000,
112
- onTransportChange: onChange,
113
- });
114
-
115
- expect(AdaptiveRpcTransport).toHaveBeenCalledWith(
116
- expect.objectContaining({
117
- grpcWebUrl: "https://api.sid.example.com",
118
- webTransportUrl: "https://wt.api.sid.example.com",
119
- webTransportCertHash: "abcdef1234567890",
120
- reconnectDelay: 3000,
121
- onTransportChange: onChange,
122
- fetchInit: { credentials: "include" },
123
- }),
124
- );
125
- });
126
-
127
- it("getTransportType delegates to AdaptiveRpcTransport.activeTransport", () => {
128
- mockAdaptiveActiveTransport.mockReturnValue("webtransport");
129
- initGrpc({
130
- baseUrl: "https://api.sid.example.com",
131
- webTransportUrl: "https://wt.api.sid.example.com",
132
- });
133
-
134
- expect(getTransportType()).toBe("webtransport");
135
40
  });
136
41
  });
137
42
 
@@ -142,8 +47,7 @@ describe("useGrpc", () => {
142
47
 
143
48
  it("returns transport after initGrpc", () => {
144
49
  initGrpc({ baseUrl: "https://api.sid.example.com" });
145
- expect(() => getTransport()).not.toThrow();
146
- expect(getTransport()).toBeDefined();
50
+ expect(getTransport()).toEqual({ type: "grpc-web-transport" });
147
51
  });
148
52
  });
149
53
 
@@ -164,36 +68,16 @@ describe("useGrpc", () => {
164
68
  expect(typeof composable.getTransport).toBe("function");
165
69
  expect(typeof composable.initGrpc).toBe("function");
166
70
  });
167
-
168
- it("transport.value is null after closeTransport", () => {
169
- initGrpc({ baseUrl: "https://api.sid.example.com" });
170
- closeTransport();
171
- const { transport } = useGrpc();
172
- expect(transport.value).toBeNull();
173
- });
174
71
  });
175
72
 
176
73
  describe("closeTransport", () => {
177
- it("calls close() on AdaptiveRpcTransport", () => {
178
- initGrpc({
179
- baseUrl: "https://api.sid.example.com",
180
- webTransportUrl: "https://wt.api.sid.example.com",
181
- });
182
-
183
- closeTransport();
184
- expect(mockAdaptiveClose).toHaveBeenCalled();
185
- });
186
-
74
+ // After close, the transport is gone: a later call must initialize
75
+ // again rather than reuse the old one.
187
76
  it("resets transport to null (getTransport throws after close)", () => {
188
77
  initGrpc({ baseUrl: "https://api.sid.example.com" });
189
78
  closeTransport();
190
79
  expect(() => getTransport()).toThrow("gRPC transport not initialized");
191
- });
192
-
193
- it("does not call close() on plain GrpcWebFetchTransport (no close method)", () => {
194
- initGrpc({ baseUrl: "https://api.sid.example.com" });
195
- // Should not throw even though GrpcWebFetchTransport has no close()
196
- expect(() => closeTransport()).not.toThrow();
80
+ expect(useGrpc().transport.value).toBeNull();
197
81
  });
198
82
  });
199
83
  });
@@ -2,67 +2,28 @@ import { GrpcWebFetchTransport } from "@protobuf-ts/grpcweb-transport";
2
2
  import type { RpcTransport } from "@protobuf-ts/runtime-rpc";
3
3
  import { ref, type Ref } from "vue";
4
4
  import type { GrpcConfig } from "../types/auth";
5
- import {
6
- AdaptiveRpcTransport,
7
- type TransportType,
8
- } from "@structured-id/proto/transport";
9
5
 
10
- /** Shared RPC transport instance (gRPC-web or AdaptiveRpcTransport). */
6
+ /** Shared gRPC-web transport instance. */
11
7
  let _transport: RpcTransport | null = null;
12
- /** Separate reference to AdaptiveRpcTransport when active (avoids instanceof). */
13
- let _adaptive: AdaptiveRpcTransport | null = null;
14
8
 
15
9
  /**
16
- * Initialize the gRPC transport. Call once at app startup.
10
+ * Initialize the gRPC-web transport. Call once at app startup.
17
11
  *
18
- * When `config.webTransportUrl` is provided, creates an AdaptiveRpcTransport
19
- * with WebTransport as the primary and gRPC-web as the fallback. The initial
20
- * WebTransport connection is attempted asynchronously (non-blocking).
21
- *
22
- * Without `config.webTransportUrl`, falls back to plain gRPC-web (same as before).
23
- *
24
- * After BFF migration (#536i-3): `credentials: "include"` is always set
25
- * so the httpOnly BFF session cookie is sent with every gRPC-web request.
12
+ * `credentials: "include"` is always set so the httpOnly BFF session cookie
13
+ * is sent with every request.
26
14
  *
27
15
  * @example
28
16
  * ```ts
29
- * // gRPC-web only
30
17
  * initGrpc({ baseUrl: 'https://api.example.com' })
31
- *
32
- * // With WebTransport failover
33
- * initGrpc({
34
- * baseUrl: 'https://api.example.com',
35
- * webTransportUrl: 'https://wt.api.example.com',
36
- * })
37
18
  * ```
38
19
  */
39
20
  export function initGrpc(config: GrpcConfig): void {
40
- if (config.webTransportUrl) {
41
- const adaptive = new AdaptiveRpcTransport({
42
- grpcWebUrl: config.baseUrl,
43
- webTransportUrl: config.webTransportUrl,
44
- webTransportCertHash: config.webTransportCertHash,
45
- reconnectDelay: config.webTransportReconnectDelay,
46
- onTransportChange: config.onTransportChange,
47
- // Always include credentials so the BFF session cookie is attached.
48
- // The BFF proxy exchanges the cookie for a Bearer token server-side.
49
- fetchInit: { credentials: "include" },
50
- });
51
- // Attempt WebTransport connection in background — non-blocking.
52
- adaptive.init().catch(() => {
53
- // Silently falls back to gRPC-web if WebTransport is unavailable.
54
- });
55
- _adaptive = adaptive;
56
- _transport = adaptive;
57
- } else {
58
- _adaptive = null;
59
- _transport = new GrpcWebFetchTransport({
60
- baseUrl: config.baseUrl,
61
- // Always include credentials so the BFF session cookie is attached.
62
- // The BFF proxy exchanges the cookie for a Bearer token server-side.
63
- fetchInit: { credentials: "include" },
64
- });
65
- }
21
+ _transport = new GrpcWebFetchTransport({
22
+ baseUrl: config.baseUrl,
23
+ // The BFF proxy exchanges the session cookie for a Bearer token
24
+ // server-side.
25
+ fetchInit: { credentials: "include" },
26
+ });
66
27
  }
67
28
 
68
29
  /**
@@ -78,24 +39,12 @@ export function getTransport(): RpcTransport {
78
39
  }
79
40
 
80
41
  /**
81
- * Close the transport and release resources (timers, connections).
82
- * Call during app teardown if using WebTransport.
42
+ * Drop the shared transport; the next use needs `initGrpc()` again.
83
43
  */
84
44
  export function closeTransport(): void {
85
- _adaptive?.close();
86
- _adaptive = null;
87
45
  _transport = null;
88
46
  }
89
47
 
90
- /**
91
- * Get the currently active transport type.
92
- * Returns "grpc-web" if AdaptiveRpcTransport is not used or WebTransport
93
- * is not available.
94
- */
95
- export function getTransportType(): TransportType {
96
- return _adaptive?.activeTransport ?? "grpc-web";
97
- }
98
-
99
48
  /**
100
49
  * Vue composable for gRPC transport.
101
50
  *
@@ -118,5 +67,3 @@ export function useGrpc() {
118
67
  initGrpc,
119
68
  };
120
69
  }
121
-
122
- export type { TransportType };
package/src/index.ts CHANGED
@@ -4,8 +4,6 @@ export {
4
4
  initGrpc,
5
5
  getTransport,
6
6
  closeTransport,
7
- getTransportType,
8
- type TransportType,
9
7
  } from "./composables/useGrpc";
10
8
  export { useSession, type SessionCallbacks } from "./composables/useSession";
11
9
  export {
@@ -37,7 +37,6 @@ const quasarStubs = {
37
37
  };
38
38
 
39
39
  const loginFn = vi.fn().mockResolvedValue({
40
- accessToken: "tok",
41
40
  expiresIn: 3600,
42
41
  sessionId: "sess-1",
43
42
  });
@@ -175,7 +175,6 @@ const props = withDefaults(
175
175
  password: string,
176
176
  onStep?: (step: number) => void,
177
177
  ) => Promise<{
178
- accessToken: string;
179
178
  expiresIn: number;
180
179
  sessionId?: string;
181
180
  }>;
@@ -207,9 +206,7 @@ const props = withDefaults(
207
206
  );
208
207
 
209
208
  const emit = defineEmits<{
210
- success: [
211
- result: { accessToken: string; expiresIn: number; sessionId?: string },
212
- ];
209
+ success: [result: { expiresIn: number; sessionId?: string }];
213
210
  error: [error: Error];
214
211
  "forgot-password": [];
215
212
  }>();
@@ -125,7 +125,6 @@ describe("createAuth prewarm", () => {
125
125
  });
126
126
  api.loginFinish.mockResolvedValue({
127
127
  sessionId: "s",
128
- accessToken: "t",
129
128
  expiresIn: 60n,
130
129
  });
131
130
  });
@@ -173,10 +172,13 @@ describe("createAuth register", () => {
173
172
  registrationResponse: RESPONSE,
174
173
  history: wireContext(),
175
174
  });
176
- api.regFinish.mockResolvedValue({
175
+ // An older server's answer, still carrying a Profile identifier the
176
+ // current message no longer has.
177
+ const fromOlderServer = {
177
178
  profileId: "profile-uuid",
178
179
  credentialId: "cred-uuid",
179
- });
180
+ };
181
+ api.regFinish.mockResolvedValue(fromOlderServer);
180
182
  });
181
183
 
182
184
  it("runs the operation: start, evaluate, prove, finish with the proof", async () => {
@@ -650,7 +652,6 @@ describe("createAuth resetPassword", () => {
650
652
  api.executeReset.mockResolvedValue({ registrationResponse: RESPONSE });
651
653
  api.completeReset.mockResolvedValue({
652
654
  sessionId: "sess-1",
653
- accessToken: "jwt",
654
655
  expiresIn: 3600,
655
656
  });
656
657
  });
@@ -686,9 +687,8 @@ describe("createAuth login", () => {
686
687
  });
687
688
  api.loginFinish.mockResolvedValue({
688
689
  sessionId: "sess-1",
689
- accessToken: "jwt",
690
690
  expiresIn: BigInt(3600),
691
- } as never);
691
+ });
692
692
  });
693
693
 
694
694
  it("signs in with the Pallas client in two round trips", async () => {
@@ -713,9 +713,10 @@ describe("createAuth login", () => {
713
713
  "server-state",
714
714
  );
715
715
  expect(steps).toEqual([1, 2, 3]);
716
+ // The result carries no credential: the browser holds the session
717
+ // cookie the answer set.
716
718
  expect(result).toEqual({
717
719
  sessionId: "sess-1",
718
- accessToken: "jwt",
719
720
  expiresIn: 3600,
720
721
  });
721
722
  });
@@ -33,10 +33,13 @@ import {
33
33
 
34
34
  // ── Types ──
35
35
 
36
- /** A sign-in's session, as the server issued it. */
36
+ /**
37
+ * A sign-in's session, as the server issued it. The credential itself never
38
+ * reaches page code: a browser holds the session cookie the answer set.
39
+ */
37
40
  export interface LoginResult {
38
41
  sessionId: string;
39
- accessToken: string;
42
+ /** Seconds until the delivered credential expires. */
40
43
  expiresIn: number;
41
44
  }
42
45
 
@@ -540,7 +543,6 @@ export function createAuth(loadClient: ZkppClientLoader) {
540
543
  );
541
544
  return {
542
545
  sessionId: finished.sessionId,
543
- accessToken: finished.accessToken,
544
546
  expiresIn: Number(finished.expiresIn),
545
547
  };
546
548
  }
@@ -71,7 +71,6 @@ describe("OPAQUE gRPC wrappers", () => {
71
71
  it("sends principal, finalization, and serverLoginState", async () => {
72
72
  const expected = {
73
73
  sessionId: "sess-1",
74
- accessToken: "jwt",
75
74
  expiresIn: BigInt(3600),
76
75
  };
77
76
  mockOpaqueLoginFinish.mockResolvedValue({ response: expected });
@@ -23,9 +23,7 @@ describe("useAuthStore (BFF cookie session)", () => {
23
23
  email: "alice@example.com",
24
24
  name: "Alice Smith",
25
25
  preferred_username: "alice",
26
- groups: ["admins"],
27
26
  acr: "urn:sid:acr:mfa",
28
- roles: ["member"],
29
27
  });
30
28
 
31
29
  expect(store.isAuthenticated).toBe(true);
package/src/types/auth.ts CHANGED
@@ -29,30 +29,4 @@ export interface SessionInfo {
29
29
  export interface GrpcConfig {
30
30
  /** Base URL for gRPC-web transport (e.g., "https://api.example.com"). */
31
31
  baseUrl: string;
32
- /**
33
- * @deprecated BFF mode always uses credentials: "include".
34
- * This field is kept for API compatibility but has no effect.
35
- */
36
- withCredentials?: boolean;
37
- /**
38
- * WebTransport server URL (e.g., "https://wt.api.example.com").
39
- * When provided, AdaptiveRpcTransport is used with WebTransport as primary
40
- * and gRPC-web as fallback. Omit to use gRPC-web only.
41
- */
42
- webTransportUrl?: string;
43
- /**
44
- * SHA-256 certificate hash (hex string) for dev self-signed WebTransport certs.
45
- * Only needed when connecting to servers with self-signed certificates.
46
- */
47
- webTransportCertHash?: string;
48
- /**
49
- * Delay in milliseconds before attempting to reconnect WebTransport after failover.
50
- * Defaults to 5000ms.
51
- */
52
- webTransportReconnectDelay?: number;
53
- /**
54
- * Callback fired when the active transport changes between "webtransport" and "grpc-web".
55
- * Use this to show transport status indicators in the UI.
56
- */
57
- onTransportChange?: (transport: "webtransport" | "grpc-web") => void;
58
32
  }
@@ -130,6 +130,8 @@ export const REFUSAL_TEXTS: Readonly<
130
130
  [ErrorReason.AUTHENTICATION_FAILED]: "Sign-in failed.",
131
131
  [ErrorReason.TOKEN_EXPIRED]: "Your session has expired; sign in again.",
132
132
  [ErrorReason.TOKEN_INVALID]: "Your session is not valid; sign in again.",
133
+ [ErrorReason.CSRF_TOKEN_INVALID]:
134
+ "This page is out of date for your session; reload it and try again.",
133
135
  [ErrorReason.INSUFFICIENT_PERMISSIONS]:
134
136
  "You do not have permission to do this.",
135
137
  [ErrorReason.SCOPE_NOT_GRANTED]: "This access was not granted.",