@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 +3 -3
- package/src/composables/useAccountTransport.test.ts +53 -45
- package/src/composables/useAccountTransport.ts +5 -5
- package/src/composables/useBffAuth.test.ts +109 -58
- package/src/composables/useBffAuth.ts +96 -83
- package/src/stores/auth.test.ts +0 -2
- package/src/utils/rpcRefusal.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@structured-id/ui-core",
|
|
3
|
-
"version": "4.
|
|
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.
|
|
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.
|
|
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
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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] =
|
|
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
|
|
46
|
-
function
|
|
47
|
-
|
|
48
|
-
"
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
78
|
+
host.getApplicationSession.mockRejectedValue(
|
|
79
|
+
new RpcError("no session", "UNAUTHENTICATED"),
|
|
80
|
+
);
|
|
68
81
|
await checkBffSession();
|
|
69
82
|
});
|
|
70
83
|
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
//
|
|
76
|
-
//
|
|
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
|
|
87
|
-
initAccountApi({ baseUrl: "
|
|
88
|
-
|
|
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
|
|
95
|
-
//
|
|
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: "
|
|
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: "
|
|
105
|
-
|
|
106
|
-
|
|
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: "
|
|
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
|
|
3
|
-
* page's own origin. The
|
|
4
|
-
* token itself; the page sends only its session cookie and, on every
|
|
5
|
-
* the CSRF token
|
|
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
|
|
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
|
|
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
|
|
10
|
-
function
|
|
11
|
-
|
|
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
|
-
/**
|
|
17
|
-
function
|
|
18
|
-
return
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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([
|
|
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
|
-
|
|
42
|
-
await expect(bffLogout()).resolves.toEqual({ ended: true, grant });
|
|
43
|
-
expect(
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
52
|
-
//
|
|
53
|
-
it.each([
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
62
|
-
|
|
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
|
-
|
|
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(
|
|
138
|
+
expect(host.endApplicationSession).not.toHaveBeenCalled();
|
|
88
139
|
});
|
|
89
140
|
|
|
90
|
-
// Whatever the
|
|
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
|
-
|
|
143
|
+
host.endApplicationSession.mockReturnValue(refused("UNAVAILABLE"));
|
|
93
144
|
await bffLogout();
|
|
94
145
|
expect(bffCsrfToken()).toBeNull();
|
|
95
146
|
});
|
|
@@ -1,107 +1,126 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* The page's application session.
|
|
3
3
|
*
|
|
4
|
-
* The tokens live in
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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(
|
|
12
|
-
* 2. Call `checkBffSession()` to
|
|
13
|
-
* 3. Call `startBffLogin()` to
|
|
14
|
-
* 4. Call `bffLogout()` to
|
|
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
|
-
/**
|
|
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
|
|
26
|
+
/** Email address, when disclosed to this application. */
|
|
22
27
|
email?: string | null;
|
|
23
|
-
/** Display name
|
|
28
|
+
/** Display name, when disclosed. */
|
|
24
29
|
name?: string | null;
|
|
25
|
-
/** Username
|
|
30
|
+
/** Username, when disclosed. */
|
|
26
31
|
preferred_username?: string | null;
|
|
27
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
40
|
-
* the session
|
|
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
|
|
56
|
+
* Initialize the session's UI host. Call once at app boot.
|
|
46
57
|
*
|
|
47
|
-
* @param baseUrl
|
|
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
|
|
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
|
|
60
|
-
*
|
|
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
|
-
*
|
|
79
|
+
* Read the page's session with `GetApplicationSession`.
|
|
68
80
|
*
|
|
69
|
-
* Returns user
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
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
|
|
78
|
-
|
|
90
|
+
const client =
|
|
91
|
+
bffBaseUrl !== undefined ? sessionClient(bffBaseUrl) : _session;
|
|
92
|
+
if (client === null) return null;
|
|
79
93
|
try {
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
114
|
+
* Send the browser to sign-in (`StartSignIn`, a navigation to
|
|
115
|
+
* `/auth/login`).
|
|
97
116
|
*
|
|
98
|
-
* After
|
|
99
|
-
*
|
|
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
|
|
102
|
-
* @param postLoginRedirect
|
|
103
|
-
*
|
|
104
|
-
*
|
|
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("[
|
|
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
|
|
123
|
-
*
|
|
124
|
-
*
|
|
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
|
|
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:
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
*
|
|
159
|
+
* End the session with `EndApplicationSession`, carrying the session's CSRF
|
|
160
|
+
* token in `x-csrf-token`.
|
|
140
161
|
*
|
|
141
|
-
* The
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
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
|
|
167
|
+
* @param bffBaseUrl Override the base URL
|
|
147
168
|
*/
|
|
148
169
|
export async function bffLogout(bffBaseUrl?: string): Promise<BffLogoutResult> {
|
|
149
|
-
const
|
|
150
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
187
|
+
* Vue composable for the session actions.
|
|
175
188
|
*/
|
|
176
189
|
export function useBffAuth() {
|
|
177
190
|
return {
|
package/src/stores/auth.test.ts
CHANGED
|
@@ -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/utils/rpcRefusal.ts
CHANGED
|
@@ -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.",
|