@structured-id/ui-core 4.14.2 → 4.15.1
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/index.ts +1 -0
- package/src/profile/components/SidLoginForm.vue +3 -6
- package/src/profile/composables/useAuth.test.ts +39 -7
- package/src/profile/composables/useAuth.ts +19 -6
- package/src/profile/composables/useAuthApi.ids.test.ts +2 -0
- package/src/profile/composables/useAuthApi.opaque.test.ts +7 -1
- package/src/profile/composables/useAuthApi.pending.test.ts +134 -0
- package/src/profile/composables/useAuthApi.ts +81 -24
- package/src/profile/composables/useLoginSuccess.test.ts +28 -1
- package/src/profile/composables/useLoginSuccess.ts +22 -1
- package/src/profile/composables/useUpstreamSignIn.test.ts +54 -21
- package/src/profile/composables/useUpstreamSignIn.ts +44 -18
- package/src/profile/index.ts +3 -0
- package/src/profile/pendingSignIn.test.ts +126 -0
- package/src/profile/pendingSignIn.ts +140 -0
- package/src/profile/upstream.test.ts +18 -10
- package/src/profile/upstream.ts +11 -8
- package/src/utils/rpcRefusal.test.ts +11 -0
- package/src/utils/rpcRefusal.ts +17 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { UpstreamProviderId } from "@structured-id/ids";
|
|
2
3
|
import {
|
|
3
4
|
UPSTREAM_CALLBACK_PATH,
|
|
4
5
|
peekUpstream,
|
|
@@ -8,6 +9,7 @@ import {
|
|
|
8
9
|
upstreamRequestOfPage,
|
|
9
10
|
} from "./upstream";
|
|
10
11
|
|
|
12
|
+
const P1 = UpstreamProviderId.parse("0192f3a4-7c1e-7b2a-9d4e-3f5a6b7c8d20");
|
|
11
13
|
const RD =
|
|
12
14
|
"https://sid.example.com/i/0123456789abcdef0123456789abcdef/oauth2/authorize?client_id=ops&request_uri=urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3Ar";
|
|
13
15
|
|
|
@@ -35,25 +37,26 @@ describe("the kept return", () => {
|
|
|
35
37
|
// The return to the callback page finds the provider and the request it
|
|
36
38
|
// started with, under the provider's state.
|
|
37
39
|
it("is found under its state", () => {
|
|
38
|
-
rememberUpstream(sessionStorage, "s1", { provider:
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
});
|
|
40
|
+
rememberUpstream(sessionStorage, "s1", { provider: P1, rd: RD });
|
|
41
|
+
const kept = peekUpstream(sessionStorage, "s1");
|
|
42
|
+
expect(kept?.provider.toString()).toBe(P1.toString());
|
|
43
|
+
expect(kept?.rd).toBe(RD);
|
|
43
44
|
expect(peekUpstream(sessionStorage, "s2")).toBeUndefined();
|
|
44
45
|
});
|
|
45
46
|
|
|
46
47
|
// A sign-in without an authorization request returns without one.
|
|
47
48
|
it("keeps the absence of a request", () => {
|
|
48
|
-
rememberUpstream(sessionStorage, "s1", { provider:
|
|
49
|
+
rememberUpstream(sessionStorage, "s1", { provider: P1, rd: null });
|
|
49
50
|
expect(peekUpstream(sessionStorage, "s1")?.rd).toBeNull();
|
|
50
51
|
});
|
|
51
52
|
|
|
52
53
|
// A return completes once: taking it removes it, so a reloaded or replayed
|
|
53
54
|
// callback finds nothing to continue.
|
|
54
55
|
it("is taken once", () => {
|
|
55
|
-
rememberUpstream(sessionStorage, "s1", { provider:
|
|
56
|
-
expect(takeUpstream(sessionStorage, "s1")?.provider).toBe(
|
|
56
|
+
rememberUpstream(sessionStorage, "s1", { provider: P1, rd: RD });
|
|
57
|
+
expect(takeUpstream(sessionStorage, "s1")?.provider.toString()).toBe(
|
|
58
|
+
P1.toString(),
|
|
59
|
+
);
|
|
57
60
|
expect(takeUpstream(sessionStorage, "s1")).toBeUndefined();
|
|
58
61
|
});
|
|
59
62
|
|
|
@@ -61,8 +64,13 @@ describe("the kept return", () => {
|
|
|
61
64
|
it("ignores what it did not write", () => {
|
|
62
65
|
sessionStorage.setItem("sid.upstream.s1", "not json");
|
|
63
66
|
sessionStorage.setItem("sid.upstream.s2", JSON.stringify({ rd: RD }));
|
|
67
|
+
sessionStorage.setItem(
|
|
68
|
+
"sid.upstream.s3",
|
|
69
|
+
JSON.stringify({ provider: "not-an-id", rd: RD }),
|
|
70
|
+
);
|
|
64
71
|
expect(peekUpstream(sessionStorage, "s1")).toBeUndefined();
|
|
65
72
|
expect(peekUpstream(sessionStorage, "s2")).toBeUndefined();
|
|
73
|
+
expect(peekUpstream(sessionStorage, "s3")).toBeUndefined();
|
|
66
74
|
});
|
|
67
75
|
});
|
|
68
76
|
|
|
@@ -84,7 +92,7 @@ describe("upstreamRequestOfPage", () => {
|
|
|
84
92
|
// provider's return does not carry; the entry stays for the page to take.
|
|
85
93
|
// An rd in the callback's own query is never used.
|
|
86
94
|
it("reads the callback's request only from its kept return", () => {
|
|
87
|
-
rememberUpstream(sessionStorage, "s1", { provider:
|
|
95
|
+
rememberUpstream(sessionStorage, "s1", { provider: P1, rd: RD });
|
|
88
96
|
const callback = location(`${UPSTREAM_CALLBACK_PATH}?code=c&state=s1`);
|
|
89
97
|
expect(upstreamRequestOfPage(callback, sessionStorage)).toBe(RD);
|
|
90
98
|
expect(peekUpstream(sessionStorage, "s1")).toBeDefined();
|
|
@@ -106,7 +114,7 @@ describe("upstreamRequestOfPage", () => {
|
|
|
106
114
|
|
|
107
115
|
// An app with its own callback path reads its own callback page.
|
|
108
116
|
it("follows the app's callback path", () => {
|
|
109
|
-
rememberUpstream(sessionStorage, "s1", { provider:
|
|
117
|
+
rememberUpstream(sessionStorage, "s1", { provider: P1, rd: RD });
|
|
110
118
|
expect(
|
|
111
119
|
upstreamRequestOfPage(
|
|
112
120
|
location("/upstream/return?state=s1"),
|
package/src/profile/upstream.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* The kept entry is no credential: the issuer keeps the request's state,
|
|
10
10
|
* PKCE verifier and nonce itself and checks `state` on completion.
|
|
11
11
|
*/
|
|
12
|
+
import { UpstreamProviderId } from "@structured-id/ids";
|
|
12
13
|
|
|
13
14
|
/** Where the provider returns the browser, unless the app names its own. */
|
|
14
15
|
export const UPSTREAM_CALLBACK_PATH = "/account/upstream/callback";
|
|
@@ -27,20 +28,23 @@ export function upstreamCallbackUri(
|
|
|
27
28
|
/** What the return to the callback page needs. */
|
|
28
29
|
export interface PendingUpstream {
|
|
29
30
|
/** The provider the sign-in started at. */
|
|
30
|
-
provider:
|
|
31
|
+
provider: UpstreamProviderId;
|
|
31
32
|
/** The authorization request the sign-in completes, if one sent the user. */
|
|
32
33
|
rd: string | null;
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
const PREFIX = "sid.upstream.";
|
|
36
37
|
|
|
37
|
-
/** Keep `pending` for the return carrying `state
|
|
38
|
+
/** Keep `pending` for the return carrying `state`; the provider by its text form. */
|
|
38
39
|
export function rememberUpstream(
|
|
39
40
|
storage: Storage,
|
|
40
41
|
state: string,
|
|
41
42
|
pending: PendingUpstream,
|
|
42
43
|
): void {
|
|
43
|
-
storage.setItem(
|
|
44
|
+
storage.setItem(
|
|
45
|
+
PREFIX + state,
|
|
46
|
+
JSON.stringify({ provider: pending.provider.toString(), rd: pending.rd }),
|
|
47
|
+
);
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
/** What was kept for `state`, left in place; `undefined` when nothing is. */
|
|
@@ -51,12 +55,11 @@ export function peekUpstream(
|
|
|
51
55
|
const kept = storage.getItem(PREFIX + state);
|
|
52
56
|
if (kept === null) return undefined;
|
|
53
57
|
try {
|
|
54
|
-
const parsed = JSON.parse(kept) as
|
|
55
|
-
if (typeof parsed.provider !== "string"
|
|
56
|
-
return undefined;
|
|
57
|
-
}
|
|
58
|
+
const parsed = JSON.parse(kept) as { provider?: unknown; rd?: unknown };
|
|
59
|
+
if (typeof parsed.provider !== "string") return undefined;
|
|
58
60
|
return {
|
|
59
|
-
provider
|
|
61
|
+
// An entry whose provider is no identifier was not kept by this code.
|
|
62
|
+
provider: UpstreamProviderId.parse(parsed.provider),
|
|
60
63
|
rd: typeof parsed.rd === "string" ? parsed.rd : null,
|
|
61
64
|
};
|
|
62
65
|
} catch {
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
refusalMessage,
|
|
15
15
|
rpcReason,
|
|
16
16
|
rpcRefusal,
|
|
17
|
+
SignInRefusedError,
|
|
17
18
|
} from "./rpcRefusal";
|
|
18
19
|
|
|
19
20
|
/** A refused call as the gRPC-Web transport raises it: the status in the trailer. */
|
|
@@ -143,6 +144,16 @@ describe("rpcRefusal", () => {
|
|
|
143
144
|
);
|
|
144
145
|
});
|
|
145
146
|
|
|
147
|
+
// A password the device refused reads as the server's refusal of a sign-in,
|
|
148
|
+
// never as the client's own wording, so a wrong password and an unknown
|
|
149
|
+
// account look alike.
|
|
150
|
+
it("shows a refused sign-in as the server's sign-in refusal", () => {
|
|
151
|
+
const refused = new Error("invalid login");
|
|
152
|
+
expect(
|
|
153
|
+
refusalMessage(new SignInRefusedError(refused), "Login failed"),
|
|
154
|
+
).toBe(REFUSAL_TEXTS[ErrorReason.AUTHENTICATION_FAILED]);
|
|
155
|
+
});
|
|
156
|
+
|
|
146
157
|
// A refusal that names the fields it concerns lets the form show each
|
|
147
158
|
// text under its field: the server's text for people when it sent one,
|
|
148
159
|
// else its description.
|
package/src/utils/rpcRefusal.ts
CHANGED
|
@@ -231,15 +231,31 @@ export const REFUSAL_TEXTS: Readonly<
|
|
|
231
231
|
[ErrorReason.INTERNAL_ERROR]: "Something went wrong on the server.",
|
|
232
232
|
};
|
|
233
233
|
|
|
234
|
+
/**
|
|
235
|
+
* A password sign-in refused on this device: it did not verify (RFC 9807
|
|
236
|
+
* §6.4). It reads as the server's own refusal of a sign-in, so the text does
|
|
237
|
+
* not tell a wrong password from an unknown account.
|
|
238
|
+
*/
|
|
239
|
+
export class SignInRefusedError extends Error {
|
|
240
|
+
constructor(cause: unknown) {
|
|
241
|
+
super("The sign-in did not verify", { cause });
|
|
242
|
+
this.name = "SignInRefusedError";
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
234
246
|
/**
|
|
235
247
|
* The text to show for a failed call: the server's text for people, else the
|
|
236
248
|
* client's text for SID's reason, else `fallback`, the caller's context. A
|
|
237
|
-
* client-side `Error` (no call refused) keeps its own message
|
|
249
|
+
* client-side `Error` (no call refused) keeps its own message, except a
|
|
250
|
+
* refused sign-in, which reads as the server's refusal of one.
|
|
238
251
|
*/
|
|
239
252
|
export function refusalMessage(
|
|
240
253
|
err: unknown,
|
|
241
254
|
fallback = "Request failed",
|
|
242
255
|
): string {
|
|
256
|
+
if (err instanceof SignInRefusedError) {
|
|
257
|
+
return REFUSAL_TEXTS[ErrorReason.AUTHENTICATION_FAILED];
|
|
258
|
+
}
|
|
243
259
|
const refusal = rpcRefusal(err);
|
|
244
260
|
if (refusal) {
|
|
245
261
|
if (refusal.localized) return refusal.localized;
|