@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 +2 -2
- package/src/composables/index.ts +1 -8
- 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/composables/useGrpc.test.ts +9 -125
- package/src/composables/useGrpc.ts +11 -64
- package/src/index.ts +0 -2
- package/src/profile/components/SidLoginForm.test.ts +0 -1
- package/src/profile/components/SidLoginForm.vue +1 -4
- package/src/profile/composables/useAuth.test.ts +8 -7
- package/src/profile/composables/useAuth.ts +5 -3
- package/src/profile/composables/useAuthApi.opaque.test.ts +0 -1
- package/src/stores/auth.test.ts +0 -2
- package/src/types/auth.ts +0 -26
- 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.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.
|
|
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"
|
package/src/composables/index.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
@@ -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 {
|
|
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
|
|
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(
|
|
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
|
-
|
|
178
|
-
|
|
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
|
|
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
|
-
*
|
|
19
|
-
*
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
*
|
|
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
|
@@ -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
|
-
|
|
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
|
-
}
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 });
|
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/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
|
}
|
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.",
|