@structured-id/ui-core 4.4.0 → 4.6.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.4.0",
3
+ "version": "4.6.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.4.1",
45
+ "@structured-id/proto": "^0.6.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"
@@ -79,6 +79,6 @@
79
79
  "optional": true
80
80
  }
81
81
  },
82
- "packageManager": "yarn@4.14.1",
82
+ "packageManager": "yarn@4.18.1",
83
83
  "license": "AGPL-3.0-only"
84
84
  }
@@ -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 {
@@ -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);
@@ -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.",