@structured-id/ui-core 4.5.0 → 4.7.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/profile/composables/index.ts +1 -0
- package/src/profile/composables/useUpstreamSignIn.test.ts +275 -0
- package/src/profile/composables/useUpstreamSignIn.ts +280 -0
- package/src/profile/index.ts +3 -0
- package/src/profile/upstream.test.ts +118 -0
- package/src/profile/upstream.ts +93 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@structured-id/ui-core",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.7.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
|
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { setActivePinia, createPinia } from "pinia";
|
|
3
|
+
import { createApp, defineComponent } from "vue";
|
|
4
|
+
import { createRouter, createMemoryHistory } from "vue-router";
|
|
5
|
+
import {
|
|
6
|
+
abandonUpstream,
|
|
7
|
+
completeUpstream,
|
|
8
|
+
startUpstream,
|
|
9
|
+
useUpstreamCallback,
|
|
10
|
+
type UpstreamApi,
|
|
11
|
+
type UpstreamBrowser,
|
|
12
|
+
} from "./useUpstreamSignIn";
|
|
13
|
+
import { UPSTREAM_CALLBACK_PATH, peekUpstream } from "../upstream";
|
|
14
|
+
|
|
15
|
+
vi.mock("../../composables/useBffAuth", () => ({
|
|
16
|
+
checkBffSession: vi.fn().mockResolvedValue(null),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
const ORIGIN = "https://ee.sid.example.com";
|
|
20
|
+
const ISSUER = "https://sid.example.com";
|
|
21
|
+
const RD = `${ISSUER}/i/0123456789abcdef0123456789abcdef/oauth2/authorize?client_id=ops&request_uri=${encodeURIComponent("urn:ietf:params:oauth:request_uri:r")}`;
|
|
22
|
+
|
|
23
|
+
function api(outcome: "signed-in" | "linking-required" = "signed-in") {
|
|
24
|
+
return {
|
|
25
|
+
list: vi.fn().mockResolvedValue([]),
|
|
26
|
+
initiate: vi.fn().mockResolvedValue({
|
|
27
|
+
authorizationUrl:
|
|
28
|
+
"https://central.sid.example.com/i/h/oauth2/authorize?x=1",
|
|
29
|
+
stateToken: "s1",
|
|
30
|
+
}),
|
|
31
|
+
complete: vi.fn().mockResolvedValue(outcome),
|
|
32
|
+
} satisfies UpstreamApi;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function browser(): UpstreamBrowser & { left: string[] } {
|
|
36
|
+
const left: string[] = [];
|
|
37
|
+
return {
|
|
38
|
+
origin: ORIGIN,
|
|
39
|
+
storage: sessionStorage,
|
|
40
|
+
callbackPath: UPSTREAM_CALLBACK_PATH,
|
|
41
|
+
leaveTo: (url) => left.push(url),
|
|
42
|
+
left,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
beforeEach(() => sessionStorage.clear());
|
|
47
|
+
|
|
48
|
+
describe("startUpstream", () => {
|
|
49
|
+
// The issuer starts the sign-in for this origin's exact callback page; the
|
|
50
|
+
// browser leaves for the provider with the request kept under its state.
|
|
51
|
+
it("starts at the issuer and leaves for the provider", async () => {
|
|
52
|
+
const calls = api();
|
|
53
|
+
const page = browser();
|
|
54
|
+
await startUpstream("p1", RD, calls, page);
|
|
55
|
+
expect(calls.initiate).toHaveBeenCalledWith(
|
|
56
|
+
"p1",
|
|
57
|
+
`${ORIGIN}${UPSTREAM_CALLBACK_PATH}`,
|
|
58
|
+
);
|
|
59
|
+
expect(page.left).toEqual([
|
|
60
|
+
"https://central.sid.example.com/i/h/oauth2/authorize?x=1",
|
|
61
|
+
]);
|
|
62
|
+
expect(peekUpstream(sessionStorage, "s1")).toEqual({
|
|
63
|
+
provider: "p1",
|
|
64
|
+
rd: RD,
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// A refused start keeps nothing and leaves nowhere.
|
|
69
|
+
it("keeps nothing when the issuer refuses", async () => {
|
|
70
|
+
const calls = api();
|
|
71
|
+
calls.initiate.mockRejectedValue(new Error("refused"));
|
|
72
|
+
const page = browser();
|
|
73
|
+
await expect(startUpstream("p1", RD, calls, page)).rejects.toThrow(
|
|
74
|
+
"refused",
|
|
75
|
+
);
|
|
76
|
+
expect(page.left).toEqual([]);
|
|
77
|
+
expect(sessionStorage.length).toBe(0);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("abandonUpstream", () => {
|
|
82
|
+
// A provider's error return (RFC 6749 §4.1.2.1) still carries the state:
|
|
83
|
+
// the kept entry is taken, so the waiting request is offered again and
|
|
84
|
+
// nothing is left behind.
|
|
85
|
+
it("takes the kept request of a refused return", async () => {
|
|
86
|
+
const page = browser();
|
|
87
|
+
await startUpstream("p1", RD, api(), page);
|
|
88
|
+
expect(abandonUpstream("s1", page)).toBe(RD);
|
|
89
|
+
expect(peekUpstream(sessionStorage, "s1")).toBeUndefined();
|
|
90
|
+
expect(abandonUpstream("s1", page)).toBeNull();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("completeUpstream", () => {
|
|
95
|
+
// The return completes at the issuer with the provider and the callback
|
|
96
|
+
// URI it started with, and continues the kept request.
|
|
97
|
+
it("completes the sign-in this tab started", async () => {
|
|
98
|
+
const calls = api();
|
|
99
|
+
const page = browser();
|
|
100
|
+
await startUpstream("p1", RD, calls, page);
|
|
101
|
+
const done = await completeUpstream("c1", "s1", calls, page);
|
|
102
|
+
expect(calls.complete).toHaveBeenCalledWith(
|
|
103
|
+
"p1",
|
|
104
|
+
"c1",
|
|
105
|
+
"s1",
|
|
106
|
+
`${ORIGIN}${UPSTREAM_CALLBACK_PATH}`,
|
|
107
|
+
);
|
|
108
|
+
expect(done).toEqual({ outcome: "signed-in", rd: RD });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// A return this tab did not start, or one already completed, asks the
|
|
112
|
+
// issuer nothing.
|
|
113
|
+
it("completes nothing it did not start, and nothing twice", async () => {
|
|
114
|
+
const calls = api();
|
|
115
|
+
const page = browser();
|
|
116
|
+
expect(await completeUpstream("c1", "s1", calls, page)).toEqual({
|
|
117
|
+
outcome: "unknown",
|
|
118
|
+
});
|
|
119
|
+
await startUpstream("p1", RD, calls, page);
|
|
120
|
+
await completeUpstream("c1", "s1", calls, page);
|
|
121
|
+
expect(await completeUpstream("c1", "s1", calls, page)).toEqual({
|
|
122
|
+
outcome: "unknown",
|
|
123
|
+
});
|
|
124
|
+
expect(calls.complete).toHaveBeenCalledTimes(1);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// An upstream account the issuer does not know is no sign-in.
|
|
128
|
+
it("reports an account that needs linking", async () => {
|
|
129
|
+
const calls = api("linking-required");
|
|
130
|
+
const page = browser();
|
|
131
|
+
await startUpstream("p1", RD, calls, page);
|
|
132
|
+
expect(await completeUpstream("c1", "s1", calls, page)).toEqual({
|
|
133
|
+
outcome: "linking-required",
|
|
134
|
+
rd: RD,
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
/** The callback composable on a real router at `at`. */
|
|
140
|
+
async function callbackAt(
|
|
141
|
+
at: string,
|
|
142
|
+
calls: UpstreamApi,
|
|
143
|
+
page: UpstreamBrowser,
|
|
144
|
+
) {
|
|
145
|
+
const router = createRouter({
|
|
146
|
+
history: createMemoryHistory(),
|
|
147
|
+
routes: [
|
|
148
|
+
{ path: UPSTREAM_CALLBACK_PATH, component: { template: "<div />" } },
|
|
149
|
+
{ path: "/account", component: { template: "<div />" } },
|
|
150
|
+
],
|
|
151
|
+
});
|
|
152
|
+
await router.push(at);
|
|
153
|
+
await router.isReady();
|
|
154
|
+
const pinia = createPinia();
|
|
155
|
+
setActivePinia(pinia);
|
|
156
|
+
const left: string[] = [];
|
|
157
|
+
let result!: ReturnType<typeof useUpstreamCallback>;
|
|
158
|
+
const app = createApp(
|
|
159
|
+
defineComponent({
|
|
160
|
+
setup() {
|
|
161
|
+
result = useUpstreamCallback("/account", {
|
|
162
|
+
issuerOrigin: ISSUER,
|
|
163
|
+
leaveTo: (url) => left.push(url),
|
|
164
|
+
api: calls,
|
|
165
|
+
browser: page,
|
|
166
|
+
});
|
|
167
|
+
return () => null;
|
|
168
|
+
},
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
app.use(pinia);
|
|
172
|
+
app.use(router);
|
|
173
|
+
app.mount(document.createElement("div"));
|
|
174
|
+
return { result, router, left };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
describe("useUpstreamCallback", () => {
|
|
178
|
+
// The kept request continues at the issuer.
|
|
179
|
+
it("continues the request kept for the state", async () => {
|
|
180
|
+
const calls = api();
|
|
181
|
+
const page = browser();
|
|
182
|
+
await startUpstream("p1", RD, calls, page);
|
|
183
|
+
const { result, left } = await callbackAt(
|
|
184
|
+
`${UPSTREAM_CALLBACK_PATH}?code=c1&state=s1`,
|
|
185
|
+
calls,
|
|
186
|
+
page,
|
|
187
|
+
);
|
|
188
|
+
await result.complete();
|
|
189
|
+
expect(result.failure.value).toBeNull();
|
|
190
|
+
expect(left).toEqual([RD]);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// A sign-in started without a request enters the app: an rd added to the
|
|
194
|
+
// callback URL is not continued, whatever it names.
|
|
195
|
+
it("continues no request named only by the callback's own query", async () => {
|
|
196
|
+
const calls = api();
|
|
197
|
+
const page = browser();
|
|
198
|
+
await startUpstream("p1", null, calls, page);
|
|
199
|
+
const { result, router, left } = await callbackAt(
|
|
200
|
+
`${UPSTREAM_CALLBACK_PATH}?code=c1&state=s1&rd=${encodeURIComponent(RD)}`,
|
|
201
|
+
calls,
|
|
202
|
+
page,
|
|
203
|
+
);
|
|
204
|
+
await result.complete();
|
|
205
|
+
expect(left).toEqual([]);
|
|
206
|
+
expect(router.currentRoute.value.path).toBe("/account");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// A provider error takes the kept entry and keeps its request for signing
|
|
210
|
+
// in another way; nothing is completed at the issuer.
|
|
211
|
+
it("answers a provider error with the waiting request", async () => {
|
|
212
|
+
const calls = api();
|
|
213
|
+
const page = browser();
|
|
214
|
+
await startUpstream("p1", RD, calls, page);
|
|
215
|
+
const { result } = await callbackAt(
|
|
216
|
+
`${UPSTREAM_CALLBACK_PATH}?error=access_denied&state=s1`,
|
|
217
|
+
calls,
|
|
218
|
+
page,
|
|
219
|
+
);
|
|
220
|
+
await result.complete();
|
|
221
|
+
expect(result.failure.value).toBe("provider-error");
|
|
222
|
+
expect(result.continued.value).toBe(RD);
|
|
223
|
+
expect(peekUpstream(sessionStorage, "s1")).toBeUndefined();
|
|
224
|
+
expect(calls.complete).not.toHaveBeenCalled();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// The issuer failing the completion still answers the waiting request:
|
|
228
|
+
// the kept entry is gone (one-shot), so its rd must reach the page.
|
|
229
|
+
it("keeps the waiting request when the issuer fails", async () => {
|
|
230
|
+
const calls = api();
|
|
231
|
+
calls.complete.mockRejectedValue(new Error("unavailable"));
|
|
232
|
+
const page = browser();
|
|
233
|
+
await startUpstream("p1", RD, calls, page);
|
|
234
|
+
const { result, left } = await callbackAt(
|
|
235
|
+
`${UPSTREAM_CALLBACK_PATH}?code=c1&state=s1`,
|
|
236
|
+
calls,
|
|
237
|
+
page,
|
|
238
|
+
);
|
|
239
|
+
await result.complete();
|
|
240
|
+
expect(result.failure.value).toBe("failed");
|
|
241
|
+
expect(result.continued.value).toBe(RD);
|
|
242
|
+
expect(peekUpstream(sessionStorage, "s1")).toBeUndefined();
|
|
243
|
+
expect(left).toEqual([]);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// A return this tab did not start signs nobody in.
|
|
247
|
+
it("completes nothing this tab did not start", async () => {
|
|
248
|
+
const calls = api();
|
|
249
|
+
const { result, left } = await callbackAt(
|
|
250
|
+
`${UPSTREAM_CALLBACK_PATH}?code=c1&state=s1`,
|
|
251
|
+
calls,
|
|
252
|
+
browser(),
|
|
253
|
+
);
|
|
254
|
+
await result.complete();
|
|
255
|
+
expect(result.failure.value).toBe("unknown");
|
|
256
|
+
expect(calls.complete).not.toHaveBeenCalled();
|
|
257
|
+
expect(left).toEqual([]);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// An account that needs linking is reported with its waiting request.
|
|
261
|
+
it("reports linking with the waiting request", async () => {
|
|
262
|
+
const calls = api("linking-required");
|
|
263
|
+
const page = browser();
|
|
264
|
+
await startUpstream("p1", RD, calls, page);
|
|
265
|
+
const { result, left } = await callbackAt(
|
|
266
|
+
`${UPSTREAM_CALLBACK_PATH}?code=c1&state=s1`,
|
|
267
|
+
calls,
|
|
268
|
+
page,
|
|
269
|
+
);
|
|
270
|
+
await result.complete();
|
|
271
|
+
expect(result.failure.value).toBe("linking-required");
|
|
272
|
+
expect(result.continued.value).toBe(RD);
|
|
273
|
+
expect(left).toEqual([]);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sign-in through the upstream identity providers the issuer trusts, over
|
|
3
|
+
* the sign-in surface's transport. See `../upstream` for what is kept
|
|
4
|
+
* between leaving for the provider and its return.
|
|
5
|
+
*/
|
|
6
|
+
import { ref } from "vue";
|
|
7
|
+
import { useRoute, useRouter } from "vue-router";
|
|
8
|
+
import { getTransport } from "../../index";
|
|
9
|
+
import type { EnabledProviderInfo } from "@structured-id/proto/sid/v1/federation/upstream";
|
|
10
|
+
import { UpstreamServiceClient } from "@structured-id/proto/sid/v1/federation/upstream.client";
|
|
11
|
+
import {
|
|
12
|
+
UPSTREAM_CALLBACK_PATH,
|
|
13
|
+
rememberUpstream,
|
|
14
|
+
takeUpstream,
|
|
15
|
+
upstreamCallbackUri,
|
|
16
|
+
} from "../upstream";
|
|
17
|
+
import { useLoginSuccess, type LoginSuccessOptions } from "./useLoginSuccess";
|
|
18
|
+
|
|
19
|
+
export type { EnabledProviderInfo };
|
|
20
|
+
|
|
21
|
+
/** The calls a sign-in makes; the issuer's upstream service by default. */
|
|
22
|
+
export interface UpstreamApi {
|
|
23
|
+
list(): Promise<EnabledProviderInfo[]>;
|
|
24
|
+
initiate(
|
|
25
|
+
provider: string,
|
|
26
|
+
redirectUri: string,
|
|
27
|
+
): Promise<{ authorizationUrl: string; stateToken: string }>;
|
|
28
|
+
complete(
|
|
29
|
+
provider: string,
|
|
30
|
+
code: string,
|
|
31
|
+
state: string,
|
|
32
|
+
redirectUri: string,
|
|
33
|
+
): Promise<"signed-in" | "linking-required">;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The issuer's upstream service on the sign-in surface. */
|
|
37
|
+
export function upstreamApi(): UpstreamApi {
|
|
38
|
+
const client = () => new UpstreamServiceClient(getTransport());
|
|
39
|
+
return {
|
|
40
|
+
async list() {
|
|
41
|
+
const { response } = await client().listEnabledProviders({});
|
|
42
|
+
return [...response.providers].sort(
|
|
43
|
+
(a, b) => a.displayOrder - b.displayOrder,
|
|
44
|
+
);
|
|
45
|
+
},
|
|
46
|
+
async initiate(provider, redirectUri) {
|
|
47
|
+
const { response } = await client().initiateUpstreamAuth({
|
|
48
|
+
providerId: provider,
|
|
49
|
+
redirectUri,
|
|
50
|
+
});
|
|
51
|
+
return response;
|
|
52
|
+
},
|
|
53
|
+
async complete(provider, code, state, redirectUri) {
|
|
54
|
+
const { response } = await client().handleUpstreamCallback({
|
|
55
|
+
providerId: provider,
|
|
56
|
+
code,
|
|
57
|
+
state,
|
|
58
|
+
redirectUri,
|
|
59
|
+
});
|
|
60
|
+
switch (response.result.oneofKind) {
|
|
61
|
+
case "success":
|
|
62
|
+
return "signed-in";
|
|
63
|
+
case "linkingRequired":
|
|
64
|
+
return "linking-required";
|
|
65
|
+
default:
|
|
66
|
+
throw new Error("The issuer answered no sign-in outcome");
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Where the browser goes and what it keeps: the page's by default. */
|
|
73
|
+
export interface UpstreamBrowser {
|
|
74
|
+
origin: string;
|
|
75
|
+
storage: Storage;
|
|
76
|
+
/** The app's callback page path. */
|
|
77
|
+
callbackPath: string;
|
|
78
|
+
leaveTo(url: string): void;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The current page, returning to `callbackPath`. */
|
|
82
|
+
export function pageBrowser(
|
|
83
|
+
callbackPath: string = UPSTREAM_CALLBACK_PATH,
|
|
84
|
+
): UpstreamBrowser {
|
|
85
|
+
return {
|
|
86
|
+
origin: window.location.origin,
|
|
87
|
+
storage: sessionStorage,
|
|
88
|
+
callbackPath,
|
|
89
|
+
leaveTo: (url) => window.location.assign(url),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Leave for `provider`, keeping the request `rd` for the return: the issuer
|
|
95
|
+
* starts the sign-in for this origin's callback page.
|
|
96
|
+
*/
|
|
97
|
+
export async function startUpstream(
|
|
98
|
+
provider: string,
|
|
99
|
+
rd: string | null,
|
|
100
|
+
api: UpstreamApi = upstreamApi(),
|
|
101
|
+
browser: UpstreamBrowser = pageBrowser(),
|
|
102
|
+
): Promise<void> {
|
|
103
|
+
const started = await api.initiate(
|
|
104
|
+
provider,
|
|
105
|
+
upstreamCallbackUri(browser.origin, browser.callbackPath),
|
|
106
|
+
);
|
|
107
|
+
rememberUpstream(browser.storage, started.stateToken, { provider, rd });
|
|
108
|
+
browser.leaveTo(started.authorizationUrl);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Give up the return carrying `state`, which the provider answered with an
|
|
113
|
+
* error (RFC 6749 §4.1.2.1): its kept entry is taken, and the request it
|
|
114
|
+
* waited for, if any, is answered for signing in another way.
|
|
115
|
+
*/
|
|
116
|
+
export function abandonUpstream(
|
|
117
|
+
state: string,
|
|
118
|
+
browser: UpstreamBrowser = pageBrowser(),
|
|
119
|
+
): string | null {
|
|
120
|
+
return takeUpstream(browser.storage, state)?.rd ?? null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** How a return to the callback page ended. */
|
|
124
|
+
export type UpstreamReturn =
|
|
125
|
+
| {
|
|
126
|
+
/** `failed`: the issuer could not complete it. */
|
|
127
|
+
outcome: "signed-in" | "linking-required" | "failed";
|
|
128
|
+
rd: string | null;
|
|
129
|
+
}
|
|
130
|
+
| { outcome: "unknown" };
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Complete the return carrying `code` and `state` at the issuer: once, for
|
|
134
|
+
* the sign-in this tab started. A return this tab did not start, or one
|
|
135
|
+
* already completed, is `unknown`. Every other outcome, a failed one
|
|
136
|
+
* included, carries the request the return waited for: its kept entry is
|
|
137
|
+
* already taken.
|
|
138
|
+
*/
|
|
139
|
+
export async function completeUpstream(
|
|
140
|
+
code: string,
|
|
141
|
+
state: string,
|
|
142
|
+
api: UpstreamApi = upstreamApi(),
|
|
143
|
+
browser: UpstreamBrowser = pageBrowser(),
|
|
144
|
+
): Promise<UpstreamReturn> {
|
|
145
|
+
const pending = takeUpstream(browser.storage, state);
|
|
146
|
+
if (!pending) return { outcome: "unknown" };
|
|
147
|
+
try {
|
|
148
|
+
const outcome = await api.complete(
|
|
149
|
+
pending.provider,
|
|
150
|
+
code,
|
|
151
|
+
state,
|
|
152
|
+
upstreamCallbackUri(browser.origin, browser.callbackPath),
|
|
153
|
+
);
|
|
154
|
+
return { outcome, rd: pending.rd };
|
|
155
|
+
} catch {
|
|
156
|
+
return { outcome: "failed", rd: pending.rd };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Options of the upstream sign-in composables. */
|
|
161
|
+
export interface UpstreamSignInOptions {
|
|
162
|
+
/** The app's callback page path; {@link UPSTREAM_CALLBACK_PATH} by default. */
|
|
163
|
+
callbackPath?: string;
|
|
164
|
+
api?: UpstreamApi;
|
|
165
|
+
browser?: UpstreamBrowser;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The login page's providers and their buttons. */
|
|
169
|
+
export function useUpstreamSignIn(
|
|
170
|
+
rd: () => string | null,
|
|
171
|
+
options: UpstreamSignInOptions = {},
|
|
172
|
+
) {
|
|
173
|
+
const providers = ref<EnabledProviderInfo[]>([]);
|
|
174
|
+
const loading = ref<string | null>(null);
|
|
175
|
+
const error = ref<string | null>(null);
|
|
176
|
+
const api = () => options.api ?? upstreamApi();
|
|
177
|
+
|
|
178
|
+
async function load() {
|
|
179
|
+
try {
|
|
180
|
+
providers.value = await api().list();
|
|
181
|
+
} catch {
|
|
182
|
+
// Without its providers the page still signs in another way.
|
|
183
|
+
providers.value = [];
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function signInWith(provider: string) {
|
|
188
|
+
loading.value = provider;
|
|
189
|
+
error.value = null;
|
|
190
|
+
try {
|
|
191
|
+
await startUpstream(
|
|
192
|
+
provider,
|
|
193
|
+
rd(),
|
|
194
|
+
api(),
|
|
195
|
+
options.browser ?? pageBrowser(options.callbackPath),
|
|
196
|
+
);
|
|
197
|
+
} catch (e) {
|
|
198
|
+
error.value = e instanceof Error ? e.message : "Sign-in failed";
|
|
199
|
+
loading.value = null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return { providers, loading, error, load, signInWith };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Why a return to the callback page signed nobody in. */
|
|
207
|
+
export type UpstreamFailure =
|
|
208
|
+
/** The provider refused, or the user cancelled there. */
|
|
209
|
+
| "provider-error"
|
|
210
|
+
/** The return carried no code or no state. */
|
|
211
|
+
| "no-return"
|
|
212
|
+
/** This tab did not start the sign-in, or already completed it. */
|
|
213
|
+
| "unknown"
|
|
214
|
+
/** The upstream account is linked to no Profile here. */
|
|
215
|
+
| "linking-required"
|
|
216
|
+
/** The issuer could not complete it. */
|
|
217
|
+
| "failed";
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The callback page: completes the provider's return once and continues
|
|
221
|
+
* the authorization request kept for its `state` through
|
|
222
|
+
* {@link useLoginSuccess}, or into the app at `redirectTo`. The callback's
|
|
223
|
+
* own query is replaced first, so an `rd` added to it is never continued.
|
|
224
|
+
*/
|
|
225
|
+
export function useUpstreamCallback(
|
|
226
|
+
redirectTo: string,
|
|
227
|
+
options: UpstreamSignInOptions & LoginSuccessOptions = {},
|
|
228
|
+
) {
|
|
229
|
+
const route = useRoute();
|
|
230
|
+
const router = useRouter();
|
|
231
|
+
const { onLoginSuccess } = useLoginSuccess(redirectTo, options);
|
|
232
|
+
const failure = ref<UpstreamFailure | null>(null);
|
|
233
|
+
/** The request a failed return still waits for, to sign in another way. */
|
|
234
|
+
const continued = ref<string | null>(null);
|
|
235
|
+
const browser = () => options.browser ?? pageBrowser(options.callbackPath);
|
|
236
|
+
|
|
237
|
+
function text(value: unknown): string | null {
|
|
238
|
+
return typeof value === "string" && value ? value : null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function complete() {
|
|
242
|
+
const code = text(route.query.code);
|
|
243
|
+
const state = text(route.query.state);
|
|
244
|
+
if (text(route.query.error)) {
|
|
245
|
+
if (state) continued.value = abandonUpstream(state, browser());
|
|
246
|
+
failure.value = "provider-error";
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (!code || !state) {
|
|
250
|
+
failure.value = "no-return";
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
try {
|
|
254
|
+
const done = await completeUpstream(
|
|
255
|
+
code,
|
|
256
|
+
state,
|
|
257
|
+
options.api ?? upstreamApi(),
|
|
258
|
+
browser(),
|
|
259
|
+
);
|
|
260
|
+
if (done.outcome === "unknown") {
|
|
261
|
+
failure.value = "unknown";
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
continued.value = done.rd;
|
|
265
|
+
if (done.outcome !== "signed-in") {
|
|
266
|
+
failure.value = done.outcome;
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
await router.replace({
|
|
270
|
+
path: route.path,
|
|
271
|
+
query: done.rd ? { rd: done.rd } : {},
|
|
272
|
+
});
|
|
273
|
+
await onLoginSuccess();
|
|
274
|
+
} catch {
|
|
275
|
+
failure.value = "failed";
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return { failure, continued, complete };
|
|
280
|
+
}
|
package/src/profile/index.ts
CHANGED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
UPSTREAM_CALLBACK_PATH,
|
|
4
|
+
peekUpstream,
|
|
5
|
+
rememberUpstream,
|
|
6
|
+
takeUpstream,
|
|
7
|
+
upstreamCallbackUri,
|
|
8
|
+
upstreamRequestOfPage,
|
|
9
|
+
} from "./upstream";
|
|
10
|
+
|
|
11
|
+
const RD =
|
|
12
|
+
"https://sid.example.com/i/0123456789abcdef0123456789abcdef/oauth2/authorize?client_id=ops&request_uri=urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3Ar";
|
|
13
|
+
|
|
14
|
+
function location(path: string) {
|
|
15
|
+
const url = new URL(path, "https://ee.sid.example.com");
|
|
16
|
+
return { pathname: url.pathname, search: url.search };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
beforeEach(() => sessionStorage.clear());
|
|
20
|
+
|
|
21
|
+
describe("upstreamCallbackUri", () => {
|
|
22
|
+
// The provider returns to one exact URI registered for this origin, with
|
|
23
|
+
// no query: the provider compares it exactly (RFC 6749 §3.1.2).
|
|
24
|
+
it("is the callback page on this origin", () => {
|
|
25
|
+
expect(upstreamCallbackUri("https://ee.sid.example.com")).toBe(
|
|
26
|
+
`https://ee.sid.example.com${UPSTREAM_CALLBACK_PATH}`,
|
|
27
|
+
);
|
|
28
|
+
expect(
|
|
29
|
+
upstreamCallbackUri("https://admin.sid.example.com", "/upstream/return"),
|
|
30
|
+
).toBe("https://admin.sid.example.com/upstream/return");
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("the kept return", () => {
|
|
35
|
+
// The return to the callback page finds the provider and the request it
|
|
36
|
+
// started with, under the provider's state.
|
|
37
|
+
it("is found under its state", () => {
|
|
38
|
+
rememberUpstream(sessionStorage, "s1", { provider: "p1", rd: RD });
|
|
39
|
+
expect(peekUpstream(sessionStorage, "s1")).toEqual({
|
|
40
|
+
provider: "p1",
|
|
41
|
+
rd: RD,
|
|
42
|
+
});
|
|
43
|
+
expect(peekUpstream(sessionStorage, "s2")).toBeUndefined();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// A sign-in without an authorization request returns without one.
|
|
47
|
+
it("keeps the absence of a request", () => {
|
|
48
|
+
rememberUpstream(sessionStorage, "s1", { provider: "p1", rd: null });
|
|
49
|
+
expect(peekUpstream(sessionStorage, "s1")?.rd).toBeNull();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// A return completes once: taking it removes it, so a reloaded or replayed
|
|
53
|
+
// callback finds nothing to continue.
|
|
54
|
+
it("is taken once", () => {
|
|
55
|
+
rememberUpstream(sessionStorage, "s1", { provider: "p1", rd: RD });
|
|
56
|
+
expect(takeUpstream(sessionStorage, "s1")?.provider).toBe("p1");
|
|
57
|
+
expect(takeUpstream(sessionStorage, "s1")).toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// A malformed or foreign entry is no return.
|
|
61
|
+
it("ignores what it did not write", () => {
|
|
62
|
+
sessionStorage.setItem("sid.upstream.s1", "not json");
|
|
63
|
+
sessionStorage.setItem("sid.upstream.s2", JSON.stringify({ rd: RD }));
|
|
64
|
+
expect(peekUpstream(sessionStorage, "s1")).toBeUndefined();
|
|
65
|
+
expect(peekUpstream(sessionStorage, "s2")).toBeUndefined();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("upstreamRequestOfPage", () => {
|
|
70
|
+
// Every other page completes the request in its query.
|
|
71
|
+
it("reads rd from the query", () => {
|
|
72
|
+
expect(
|
|
73
|
+
upstreamRequestOfPage(
|
|
74
|
+
location(`/account/login?rd=${encodeURIComponent(RD)}`),
|
|
75
|
+
sessionStorage,
|
|
76
|
+
),
|
|
77
|
+
).toBe(RD);
|
|
78
|
+
expect(
|
|
79
|
+
upstreamRequestOfPage(location("/account/login"), sessionStorage),
|
|
80
|
+
).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// The callback page completes the request kept for its state, which the
|
|
84
|
+
// provider's return does not carry; the entry stays for the page to take.
|
|
85
|
+
// An rd in the callback's own query is never used.
|
|
86
|
+
it("reads the callback's request only from its kept return", () => {
|
|
87
|
+
rememberUpstream(sessionStorage, "s1", { provider: "p1", rd: RD });
|
|
88
|
+
const callback = location(`${UPSTREAM_CALLBACK_PATH}?code=c&state=s1`);
|
|
89
|
+
expect(upstreamRequestOfPage(callback, sessionStorage)).toBe(RD);
|
|
90
|
+
expect(peekUpstream(sessionStorage, "s1")).toBeDefined();
|
|
91
|
+
expect(
|
|
92
|
+
upstreamRequestOfPage(
|
|
93
|
+
location(`${UPSTREAM_CALLBACK_PATH}?code=c&state=other`),
|
|
94
|
+
sessionStorage,
|
|
95
|
+
),
|
|
96
|
+
).toBeNull();
|
|
97
|
+
expect(
|
|
98
|
+
upstreamRequestOfPage(
|
|
99
|
+
location(
|
|
100
|
+
`${UPSTREAM_CALLBACK_PATH}?state=other&rd=${encodeURIComponent(RD)}`,
|
|
101
|
+
),
|
|
102
|
+
sessionStorage,
|
|
103
|
+
),
|
|
104
|
+
).toBeNull();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// An app with its own callback path reads its own callback page.
|
|
108
|
+
it("follows the app's callback path", () => {
|
|
109
|
+
rememberUpstream(sessionStorage, "s1", { provider: "p1", rd: RD });
|
|
110
|
+
expect(
|
|
111
|
+
upstreamRequestOfPage(
|
|
112
|
+
location("/upstream/return?state=s1"),
|
|
113
|
+
sessionStorage,
|
|
114
|
+
"/upstream/return",
|
|
115
|
+
),
|
|
116
|
+
).toBe(RD);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sign-in through an upstream identity provider the issuer trusts. The page
|
|
3
|
+
* asks the issuer to start it, keeps what the return needs under the
|
|
4
|
+
* provider's `state` in this tab's session storage, and goes to the
|
|
5
|
+
* provider. The provider returns the browser to the callback page with
|
|
6
|
+
* `code` and `state`; the page completes the sign-in at the same issuer and
|
|
7
|
+
* continues the authorization request that sent the user there (`rd`).
|
|
8
|
+
*
|
|
9
|
+
* The kept entry is no credential: the issuer keeps the request's state,
|
|
10
|
+
* PKCE verifier and nonce itself and checks `state` on completion.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Where the provider returns the browser, unless the app names its own. */
|
|
14
|
+
export const UPSTREAM_CALLBACK_PATH = "/account/upstream/callback";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The redirect URI registered with the provider for `origin`: exact and
|
|
18
|
+
* without a query, as the provider compares it (RFC 6749 §3.1.2).
|
|
19
|
+
*/
|
|
20
|
+
export function upstreamCallbackUri(
|
|
21
|
+
origin: string,
|
|
22
|
+
path: string = UPSTREAM_CALLBACK_PATH,
|
|
23
|
+
): string {
|
|
24
|
+
return `${origin}${path}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** What the return to the callback page needs. */
|
|
28
|
+
export interface PendingUpstream {
|
|
29
|
+
/** The provider the sign-in started at. */
|
|
30
|
+
provider: string;
|
|
31
|
+
/** The authorization request the sign-in completes, if one sent the user. */
|
|
32
|
+
rd: string | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const PREFIX = "sid.upstream.";
|
|
36
|
+
|
|
37
|
+
/** Keep `pending` for the return carrying `state`. */
|
|
38
|
+
export function rememberUpstream(
|
|
39
|
+
storage: Storage,
|
|
40
|
+
state: string,
|
|
41
|
+
pending: PendingUpstream,
|
|
42
|
+
): void {
|
|
43
|
+
storage.setItem(PREFIX + state, JSON.stringify(pending));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** What was kept for `state`, left in place; `undefined` when nothing is. */
|
|
47
|
+
export function peekUpstream(
|
|
48
|
+
storage: Storage,
|
|
49
|
+
state: string,
|
|
50
|
+
): PendingUpstream | undefined {
|
|
51
|
+
const kept = storage.getItem(PREFIX + state);
|
|
52
|
+
if (kept === null) return undefined;
|
|
53
|
+
try {
|
|
54
|
+
const parsed = JSON.parse(kept) as Partial<PendingUpstream>;
|
|
55
|
+
if (typeof parsed.provider !== "string" || !parsed.provider) {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
provider: parsed.provider,
|
|
60
|
+
rd: typeof parsed.rd === "string" ? parsed.rd : null,
|
|
61
|
+
};
|
|
62
|
+
} catch {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** What was kept for `state`, removed: a return completes once. */
|
|
68
|
+
export function takeUpstream(
|
|
69
|
+
storage: Storage,
|
|
70
|
+
state: string,
|
|
71
|
+
): PendingUpstream | undefined {
|
|
72
|
+
const pending = peekUpstream(storage, state);
|
|
73
|
+
storage.removeItem(PREFIX + state);
|
|
74
|
+
return pending;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The authorization request this page load completes: `rd` in the query, or
|
|
79
|
+
* on the callback page the one kept for its `state`. The callback's own `rd`
|
|
80
|
+
* is never used: the provider's return carries none.
|
|
81
|
+
*/
|
|
82
|
+
export function upstreamRequestOfPage(
|
|
83
|
+
location: Pick<Location, "pathname" | "search">,
|
|
84
|
+
storage: Storage,
|
|
85
|
+
callbackPath: string = UPSTREAM_CALLBACK_PATH,
|
|
86
|
+
): string | null {
|
|
87
|
+
const query = new URLSearchParams(location.search);
|
|
88
|
+
if (location.pathname === callbackPath) {
|
|
89
|
+
const state = query.get("state");
|
|
90
|
+
return state ? (peekUpstream(storage, state)?.rd ?? null) : null;
|
|
91
|
+
}
|
|
92
|
+
return query.get("rd");
|
|
93
|
+
}
|