@structured-id/ui-core 1.2.0 → 1.3.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/README.md +9 -5
- package/dist/browser-vp/index.d.cts +0 -11
- package/dist/browser-vp/index.d.ts +0 -11
- package/dist/index.cjs +13721 -9514
- package/dist/index.d.cts +4362 -2375
- package/dist/index.d.ts +4362 -2375
- package/dist/index.js +13937 -9730
- package/package.json +3 -3
- package/src/browser-vp/crypto.ts +0 -1
- package/src/browser-vp/index.ts +0 -2
- package/src/browser-vp/prf.ts +0 -2
- package/src/browser-vp/storage.ts +0 -2
- package/src/browser-vp/types.ts +0 -2
- package/src/browser-vp/vp.ts +0 -2
- package/src/composables/index.ts +6 -1
- package/src/composables/useAccountTransport.test.ts +121 -0
- package/src/composables/useAccountTransport.ts +69 -0
- package/src/composables/useBffAuth.ts +44 -34
- package/src/generated/google/api/httpbody.ts +159 -0
- package/src/generated/google/protobuf/any.ts +326 -0
- package/src/generated/google/rpc/error_details.ts +1386 -0
- package/src/generated/google/rpc/status.ts +127 -0
- package/src/generated/sid/v1/account/account.ts +1 -1
- package/src/generated/sid/v1/admin/admin.client.ts +15 -12
- package/src/generated/sid/v1/admin/admin.ts +24 -89
- package/src/generated/sid/v1/admin/enrollment.client.ts +24 -12
- package/src/generated/sid/v1/admin/enrollment.ts +65 -3
- package/src/generated/sid/v1/admin/events.client.ts +8 -8
- package/src/generated/sid/v1/admin/flow.ts +1 -1
- package/src/generated/sid/v1/admin/keys.ts +1 -1
- package/src/generated/sid/v1/admin/notifications.client.ts +12 -6
- package/src/generated/sid/v1/admin/notifications.ts +11 -70
- package/src/generated/sid/v1/admin/operations.ts +20 -35
- package/src/generated/sid/v1/admin/organizations.client.ts +2 -2
- package/src/generated/sid/v1/admin/organizations.ts +15 -32
- package/src/generated/sid/v1/admin/provisioning.client.ts +232 -0
- package/src/generated/sid/v1/admin/provisioning.ts +1383 -0
- package/src/generated/sid/v1/admin/scopes.ts +1 -1
- package/src/generated/sid/v1/admin/security.client.ts +22 -24
- package/src/generated/sid/v1/admin/security.ts +13 -22
- package/src/generated/sid/v1/admin/settings.client.ts +29 -18
- package/src/generated/sid/v1/admin/settings.ts +2 -82
- package/src/generated/sid/v1/attestation/attestation.client.ts +6 -6
- package/src/generated/sid/v1/attestation/attestation.ts +8 -8
- package/src/generated/sid/v1/authn/auth.client.ts +101 -58
- package/src/generated/sid/v1/authn/auth.ts +842 -414
- package/src/generated/sid/v1/authn/flows.ts +0 -12
- package/src/generated/sid/v1/authn/issuer.client.ts +489 -0
- package/src/generated/sid/v1/authn/issuer.ts +1600 -0
- package/src/generated/sid/v1/authn/password_history.client.ts +57 -0
- package/src/generated/sid/v1/authn/password_history.ts +578 -0
- package/src/generated/sid/v1/authn/system_integration.client.ts +73 -0
- package/src/generated/sid/v1/authn/system_integration.ts +218 -0
- package/src/generated/sid/v1/authz/authz.ts +1498 -317
- package/src/generated/sid/v1/authz/forward_auth.client.ts +97 -0
- package/src/generated/sid/v1/authz/forward_auth.ts +79 -0
- package/src/generated/sid/v1/authz/governance.client.ts +2 -2
- package/src/generated/sid/v1/authz/governance.ts +61 -4
- package/src/generated/sid/v1/common/errors.ts +532 -6
- package/src/generated/sid/v1/events/event_stream.client.ts +2 -2
- package/src/generated/sid/v1/events/event_stream.ts +2 -1
- package/src/generated/sid/v1/identity/identity.ts +98 -58
- package/src/generated/sid/v1/ids/ids.ts +395 -0
- package/src/generated/sid/v1/machine/machine_user.ts +0 -36
- package/src/generated/sid/v1/machine/pat.client.ts +2 -19
- package/src/generated/sid/v1/machine/pat.ts +1 -186
- package/src/generated/sid/v1/projects/projects.client.ts +137 -19
- package/src/generated/sid/v1/projects/projects.ts +2105 -405
- package/src/generated/sid/v1/scim/scim.client.ts +248 -2
- package/src/generated/sid/v1/scim/scim.ts +153 -14
- package/src/generated/sid/v1/test/test.ts +1 -1
- package/src/i18n/index.ts +0 -11
- package/src/index.ts +36 -2
- package/src/package-exports.test.ts +18 -0
- package/src/profile/components/SidLoginForm.vue +5 -2
- package/src/profile/components/SidRegistrationForm.test.ts +58 -1
- package/src/profile/components/SidRegistrationForm.vue +110 -12
- package/src/profile/components/index.ts +4 -0
- package/src/profile/composables/index.ts +2 -1
- package/src/profile/composables/useAccountApi.ts +20 -7
- package/src/profile/composables/useAuth.test.ts +453 -0
- package/src/profile/composables/useAuth.ts +414 -0
- package/src/profile/composables/useAuthApi.opaque.test.ts +18 -0
- package/src/profile/composables/useAuthApi.ts +188 -20
- package/src/profile/composables/useEnrollmentApi.test.ts +33 -0
- package/src/profile/composables/useEnrollmentApi.ts +26 -0
- package/src/profile/composables/useIdentityApi.ts +17 -7
- package/src/profile/composables/useLoginSuccess.test.ts +97 -3
- package/src/profile/composables/useLoginSuccess.ts +80 -22
- package/src/profile/composables/useSecurityLevel.test.ts +10 -3
- package/src/profile/composables/useSecurityLevel.ts +12 -3
- package/src/quasar/SidPrincipalInput.vue +4 -7
- package/src/utils/normalizePrincipal.test.ts +19 -5
- package/src/utils/normalizePrincipal.ts +7 -9
- package/src/utils/rpcRefusal.test.ts +151 -0
- package/src/utils/rpcRefusal.ts +252 -0
- package/src/utils/validators.test.ts +9 -0
- package/src/utils/validators.ts +14 -2
- package/src/composables/useAuth.ts +0 -40
- package/src/profile/composables/useOpaqueAuth.test.ts +0 -290
- package/src/profile/composables/useOpaqueAuth.ts +0 -191
|
@@ -1,22 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared post-login success handler.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* the
|
|
4
|
+
* A sign-in that an authorization request sent the user to (`rd`, the
|
|
5
|
+
* continuation of that request at the issuer) returns the browser there: the
|
|
6
|
+
* ceremony has set the issuer's IdP session cookie, and the authorization
|
|
7
|
+
* endpoint completes the request with it. Any other `rd` is ignored, so the
|
|
8
|
+
* sign-in page never redirects to an address it did not validate.
|
|
7
9
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* 2. User authenticates on the IdP
|
|
11
|
-
* 3. BFF callback sets httpOnly session cookie
|
|
12
|
-
* 4. BFF redirects back to SPA
|
|
13
|
-
* 5. SPA calls checkBffSession() → setBffSession() → navigates to dashboard
|
|
14
|
-
*
|
|
15
|
-
* For apps where the SPA IS the auth UI (personal-ui OPAQUE flow):
|
|
16
|
-
* The OPAQUE login completes, sid-auth sets the server-side session,
|
|
17
|
-
* and this handler fetches userinfo from BFF to restore session state.
|
|
18
|
-
*
|
|
19
|
-
* Used by all apps (personal-ui, admin-ui, frontend) for consistent behavior.
|
|
10
|
+
* Otherwise the app's own session comes from its BFF: the handler reads it
|
|
11
|
+
* from the BFF's /auth/userinfo and navigates within the app.
|
|
20
12
|
*/
|
|
21
13
|
import { useRouter } from "vue-router";
|
|
22
14
|
import { useAuthStore } from "../../index";
|
|
@@ -26,29 +18,95 @@ export interface LoginSuccessResult {
|
|
|
26
18
|
sessionId?: string;
|
|
27
19
|
}
|
|
28
20
|
|
|
21
|
+
/** Options of {@link useLoginSuccess}. */
|
|
22
|
+
export interface LoginSuccessOptions {
|
|
23
|
+
/**
|
|
24
|
+
* Origin of the SID installation whose issuers may send users to this
|
|
25
|
+
* sign-in page (e.g. `https://sid.example.com`). Without it, `rd` is never
|
|
26
|
+
* followed.
|
|
27
|
+
*/
|
|
28
|
+
issuerOrigin?: string;
|
|
29
|
+
/** Leave the app for an absolute URL; defaults to `window.location.assign`. */
|
|
30
|
+
leaveTo?: (url: string) => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Path of an issuer's authorization endpoint: `/i/<32 hex>/oauth2/authorize`. */
|
|
34
|
+
const AUTHORIZE_PATH = /^\/i\/[0-9a-f]{32}\/oauth2\/authorize$/;
|
|
35
|
+
|
|
36
|
+
/** Prefix of the reference a kept authorization request is continued by. */
|
|
37
|
+
const REQUEST_URI_PREFIX = "urn:ietf:params:oauth:request_uri:";
|
|
38
|
+
|
|
29
39
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
40
|
+
* `rd` when it is exactly the continuation of an authorization request at an
|
|
41
|
+
* issuer of `issuerOrigin`: that origin, an issuer's authorization endpoint,
|
|
42
|
+
* and no parameter but `client_id` and `request_uri`. Anything else is null.
|
|
43
|
+
*/
|
|
44
|
+
export function authorizationContinuation(
|
|
45
|
+
rd: unknown,
|
|
46
|
+
issuerOrigin: string,
|
|
47
|
+
): string | null {
|
|
48
|
+
if (typeof rd !== "string") return null;
|
|
49
|
+
let url: URL;
|
|
50
|
+
let expected: string;
|
|
51
|
+
try {
|
|
52
|
+
url = new URL(rd);
|
|
53
|
+
expected = new URL(issuerOrigin).origin;
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
if (url.origin !== expected || url.username || url.password || url.hash) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
if (!AUTHORIZE_PATH.test(url.pathname)) return null;
|
|
61
|
+
const names = [...url.searchParams.keys()].sort();
|
|
62
|
+
if (
|
|
63
|
+
names.length !== 2 ||
|
|
64
|
+
names[0] !== "client_id" ||
|
|
65
|
+
names[1] !== "request_uri"
|
|
66
|
+
) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
if (!url.searchParams.get("request_uri")?.startsWith(REQUEST_URI_PREFIX)) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
if (!url.searchParams.get("client_id")) return null;
|
|
73
|
+
return url.toString();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Create a login success handler: back to the authorization request the
|
|
78
|
+
* user signs in for, or into the app at `redirectTo`.
|
|
33
79
|
*
|
|
34
80
|
* @example
|
|
35
81
|
* ```vue
|
|
36
82
|
* <script setup>
|
|
37
83
|
* import { useLoginSuccess } from "@structured-id/ui-core/profile";
|
|
38
|
-
* const { onLoginSuccess } = useLoginSuccess("/account");
|
|
84
|
+
* const { onLoginSuccess } = useLoginSuccess("/account", { issuerOrigin });
|
|
39
85
|
* </script>
|
|
40
86
|
* <template>
|
|
41
87
|
* <SidLoginForm :login-fn="login" @success="onLoginSuccess" />
|
|
42
88
|
* </template>
|
|
43
89
|
* ```
|
|
44
90
|
*/
|
|
45
|
-
export function useLoginSuccess(
|
|
91
|
+
export function useLoginSuccess(
|
|
92
|
+
redirectTo: string,
|
|
93
|
+
options: LoginSuccessOptions = {},
|
|
94
|
+
) {
|
|
46
95
|
const auth = useAuthStore();
|
|
47
96
|
const router = useRouter();
|
|
48
97
|
|
|
49
98
|
async function onLoginSuccess(_result?: LoginSuccessResult) {
|
|
50
|
-
|
|
51
|
-
|
|
99
|
+
const back = options.issuerOrigin
|
|
100
|
+
? authorizationContinuation(
|
|
101
|
+
router.currentRoute.value.query.rd,
|
|
102
|
+
options.issuerOrigin,
|
|
103
|
+
)
|
|
104
|
+
: null;
|
|
105
|
+
if (back) {
|
|
106
|
+
(options.leaveTo ?? ((url: string) => window.location.assign(url)))(back);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
52
110
|
try {
|
|
53
111
|
const userInfo = await checkBffSession();
|
|
54
112
|
if (userInfo) {
|
|
@@ -12,7 +12,6 @@ import {
|
|
|
12
12
|
type Credential,
|
|
13
13
|
type Principal,
|
|
14
14
|
} from "../../index";
|
|
15
|
-
import { PrincipalSubjectType } from "../../generated/sid/v1/identity/identity";
|
|
16
15
|
|
|
17
16
|
// ── Helpers ──
|
|
18
17
|
|
|
@@ -50,8 +49,7 @@ function makePrincipal(type: PrincipalType, verified: boolean): Principal {
|
|
|
50
49
|
value: "test",
|
|
51
50
|
verified,
|
|
52
51
|
isPrimary: false,
|
|
53
|
-
|
|
54
|
-
subjectId: "",
|
|
52
|
+
needsAddressConfirmation: false,
|
|
55
53
|
};
|
|
56
54
|
}
|
|
57
55
|
|
|
@@ -162,6 +160,15 @@ describe("recoverability", () => {
|
|
|
162
160
|
const { recoveryMethods } = useSecurityLevel(ref([]), principals);
|
|
163
161
|
expect(recoveryMethods.value).not.toContain("recovery_email");
|
|
164
162
|
});
|
|
163
|
+
|
|
164
|
+
// An email whose address awaits confirmation receives no reset, so it is
|
|
165
|
+
// no recovery channel even when it was once marked verified.
|
|
166
|
+
it("email awaiting address confirmation not counted", () => {
|
|
167
|
+
const pending = makePrincipal(PrincipalType.EMAIL, true);
|
|
168
|
+
pending.needsAddressConfirmation = true;
|
|
169
|
+
const { recoveryMethods } = useSecurityLevel(ref([]), ref([pending]));
|
|
170
|
+
expect(recoveryMethods.value).not.toContain("recovery_email");
|
|
171
|
+
});
|
|
165
172
|
});
|
|
166
173
|
|
|
167
174
|
// ── Nudges ──
|
|
@@ -180,8 +180,12 @@ export function useSecurityLevel(
|
|
|
180
180
|
methods.push("synced_passkey");
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
// An email awaiting address confirmation receives no reset.
|
|
183
184
|
const emails = identifiers.value.filter(
|
|
184
|
-
(i) =>
|
|
185
|
+
(i) =>
|
|
186
|
+
i.type === PrincipalType.EMAIL &&
|
|
187
|
+
i.verified &&
|
|
188
|
+
!i.needsAddressConfirmation,
|
|
185
189
|
);
|
|
186
190
|
if (emails.length > 0) {
|
|
187
191
|
methods.push("recovery_email");
|
|
@@ -235,10 +239,15 @@ export function useSecurityLevel(
|
|
|
235
239
|
(c) => c.type === CredentialType.RECOVERY,
|
|
236
240
|
);
|
|
237
241
|
const verifiedEmails = identifiers.value.filter(
|
|
238
|
-
(i) =>
|
|
242
|
+
(i) =>
|
|
243
|
+
i.type === PrincipalType.EMAIL &&
|
|
244
|
+
i.verified &&
|
|
245
|
+
!i.needsAddressConfirmation,
|
|
239
246
|
);
|
|
240
247
|
const unverifiedEmails = identifiers.value.filter(
|
|
241
|
-
(i) =>
|
|
248
|
+
(i) =>
|
|
249
|
+
i.type === PrincipalType.EMAIL &&
|
|
250
|
+
(!i.verified || i.needsAddressConfirmation),
|
|
242
251
|
);
|
|
243
252
|
|
|
244
253
|
if (!hasRecovery) {
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
<script setup lang="ts">
|
|
34
34
|
import { ref, computed, watch } from "vue";
|
|
35
35
|
import { isValidPhoneNumber } from "libphonenumber-js";
|
|
36
|
+
import { isValidEmail } from "../utils/validators";
|
|
36
37
|
|
|
37
38
|
export type PrincipalType = "email" | "phone" | "username" | "unknown";
|
|
38
39
|
|
|
@@ -47,8 +48,7 @@ interface Props {
|
|
|
47
48
|
|
|
48
49
|
const props = withDefaults(defineProps<Props>(), {
|
|
49
50
|
modelValue: "",
|
|
50
|
-
//
|
|
51
|
-
// ^[a-z0-9_]{3,32}$ (display form ^[a-zA-Z0-9_]{3,32}$).
|
|
51
|
+
// Username rule: ^[a-z0-9_]{3,32}$ (display form ^[a-zA-Z0-9_]{3,32}$).
|
|
52
52
|
minUsernameLength: 3,
|
|
53
53
|
required: true,
|
|
54
54
|
label: "Email, phone, or username",
|
|
@@ -80,8 +80,7 @@ function detectType(value: string): PrincipalType {
|
|
|
80
80
|
// Email: contains @ (checked after phone to exclude edge cases)
|
|
81
81
|
if (trimmed.includes("@")) return "email";
|
|
82
82
|
|
|
83
|
-
// Username
|
|
84
|
-
// ^[a-z0-9_]{3,32}$, no leading/trailing _, no __
|
|
83
|
+
// Username: ^[a-z0-9_]{3,32}$, no leading/trailing _, no __
|
|
85
84
|
// Detection is relaxed: accepts uppercase (will normalize), checks min length
|
|
86
85
|
const re = new RegExp(
|
|
87
86
|
`^[a-zA-Z0-9_]{${props.minUsernameLength},${USERNAME_MAX_LENGTH}}$`,
|
|
@@ -110,15 +109,13 @@ function onInput(value: string | number | null) {
|
|
|
110
109
|
const requiredRule = (val: string) =>
|
|
111
110
|
!props.required || !!val || "This field is required";
|
|
112
111
|
|
|
113
|
-
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
114
|
-
|
|
115
112
|
const typeRule = (val: string): string | true => {
|
|
116
113
|
if (!val) return true; // required rule handles empty
|
|
117
114
|
const type = detectType(val);
|
|
118
115
|
|
|
119
116
|
switch (type) {
|
|
120
117
|
case "email":
|
|
121
|
-
return
|
|
118
|
+
return isValidEmail(val.trim()) || "Invalid email address";
|
|
122
119
|
case "phone":
|
|
123
120
|
return isValidPhoneNumber(val) || "Invalid phone number";
|
|
124
121
|
case "username": {
|
|
@@ -99,11 +99,24 @@ describe("detectLoginInputType", () => {
|
|
|
99
99
|
});
|
|
100
100
|
|
|
101
101
|
describe("normalizePrincipal", () => {
|
|
102
|
-
|
|
103
|
-
|
|
102
|
+
// Regression: the client folded the address (case, dots, +tag) before
|
|
103
|
+
// sending it, so the server never saw the mailbox the user typed and
|
|
104
|
+
// stored the folded key as the address to mail. The server derives the
|
|
105
|
+
// lookup key and keeps the spelling; the client sends it as typed.
|
|
106
|
+
it("sends an email as typed, only trimmed", () => {
|
|
107
|
+
const result = normalizePrincipal(" Alice.Bob+tag@Example.COM ");
|
|
104
108
|
expect(result.type).toBe("email");
|
|
105
|
-
expect(result.value).toBe("
|
|
106
|
-
expect(result.display).toBe("
|
|
109
|
+
expect(result.value).toBe("Alice.Bob+tag@Example.COM");
|
|
110
|
+
expect(result.display).toBe("Alice.Bob+tag@Example.COM");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Admission is the server's policy: an installation's internal mail
|
|
114
|
+
// domain and a quoted local part are not refused by the client.
|
|
115
|
+
it("leaves email admission to the server", () => {
|
|
116
|
+
expect(normalizePrincipal("admin@printer").value).toBe("admin@printer");
|
|
117
|
+
expect(normalizePrincipal('"a b"@example.com').value).toBe(
|
|
118
|
+
'"a b"@example.com',
|
|
119
|
+
);
|
|
107
120
|
});
|
|
108
121
|
|
|
109
122
|
it("normalizes phone to E.164", () => {
|
|
@@ -137,8 +150,9 @@ describe("normalizePrincipal", () => {
|
|
|
137
150
|
expect(() => normalizePrincipal("alice", 6)).toThrow("Cannot determine");
|
|
138
151
|
});
|
|
139
152
|
|
|
140
|
-
it("throws on
|
|
153
|
+
it("throws on an email with no local part or domain", () => {
|
|
141
154
|
expect(() => normalizePrincipal("alice@")).toThrow("Invalid email");
|
|
155
|
+
expect(() => normalizePrincipal("@example.com")).toThrow("Invalid email");
|
|
142
156
|
});
|
|
143
157
|
|
|
144
158
|
it("throws on invalid phone", () => {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import parsePhoneNumberFromString, {
|
|
2
2
|
isValidPhoneNumber,
|
|
3
3
|
} from "libphonenumber-js";
|
|
4
|
+
import { isValidEmail } from "./validators";
|
|
4
5
|
|
|
5
6
|
export type LoginInputType = "email" | "phone" | "username" | "unknown";
|
|
6
7
|
|
|
@@ -13,7 +14,7 @@ export interface NormalizedPrincipal {
|
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
|
-
* Global username rules
|
|
17
|
+
* Global username rules:
|
|
17
18
|
* Display: ^[a-zA-Z0-9_]{3,32}$ — user's original input, mixed case preserved
|
|
18
19
|
* Normalized: ^[a-z0-9_]{3,32}$ — lowercase, for storage + uniqueness + login
|
|
19
20
|
* - cannot start or end with underscore
|
|
@@ -76,7 +77,7 @@ export function detectLoginInputType(
|
|
|
76
77
|
/**
|
|
77
78
|
* Normalize principal value for server submission.
|
|
78
79
|
*
|
|
79
|
-
* - Email:
|
|
80
|
+
* - Email: as typed (trimmed); the server applies the email policy
|
|
80
81
|
* - Phone: E.164 format via libphonenumber-js
|
|
81
82
|
* - Username: lowercase, validate per arch rules
|
|
82
83
|
*
|
|
@@ -92,15 +93,12 @@ export function normalizePrincipal(
|
|
|
92
93
|
|
|
93
94
|
switch (type) {
|
|
94
95
|
case "email": {
|
|
95
|
-
|
|
96
|
-
|
|
96
|
+
// Sent as typed: the server validates it, derives the lookup key and
|
|
97
|
+
// keeps this spelling as the address to mail.
|
|
98
|
+
if (!isValidEmail(trimmed)) {
|
|
97
99
|
throw new Error("Invalid email address");
|
|
98
100
|
}
|
|
99
|
-
|
|
100
|
-
const [localRaw, domain] = trimmed.split("@");
|
|
101
|
-
const local = localRaw.replace(/\./g, "").replace(/\+.*$/, "");
|
|
102
|
-
const normalized = `${local}@${domain}`.toLowerCase();
|
|
103
|
-
return { value: normalized, display: normalized, type: "email" };
|
|
101
|
+
return { value: trimmed, display: trimmed, type: "email" };
|
|
104
102
|
}
|
|
105
103
|
|
|
106
104
|
case "phone": {
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { RpcError } from "@protobuf-ts/runtime-rpc";
|
|
3
|
+
import { base64encode } from "@protobuf-ts/runtime";
|
|
4
|
+
import { Any } from "../generated/google/protobuf/any";
|
|
5
|
+
import { Status } from "../generated/google/rpc/status";
|
|
6
|
+
import {
|
|
7
|
+
ErrorInfo,
|
|
8
|
+
LocalizedMessage,
|
|
9
|
+
} from "../generated/google/rpc/error_details";
|
|
10
|
+
import { ErrorReason } from "../generated/sid/v1/common/errors";
|
|
11
|
+
import {
|
|
12
|
+
REFUSAL_TEXTS,
|
|
13
|
+
refusalMessage,
|
|
14
|
+
rpcReason,
|
|
15
|
+
rpcRefusal,
|
|
16
|
+
} from "./rpcRefusal";
|
|
17
|
+
|
|
18
|
+
/** A refused call as the gRPC-Web transport raises it: the status in the trailer. */
|
|
19
|
+
function refused(
|
|
20
|
+
details: Any[],
|
|
21
|
+
message = "this%20password%20was%20used%20before",
|
|
22
|
+
): RpcError {
|
|
23
|
+
const status = Status.create({ code: 9, message, details });
|
|
24
|
+
return new RpcError(message, "FAILED_PRECONDITION", {
|
|
25
|
+
"grpc-status-details-bin": base64encode(Status.toBinary(status)),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const info = Any.pack(
|
|
30
|
+
ErrorInfo.create({
|
|
31
|
+
reason: "PASSWORD_REUSED",
|
|
32
|
+
domain: "structured.id",
|
|
33
|
+
metadata: {},
|
|
34
|
+
}),
|
|
35
|
+
ErrorInfo,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
describe("rpcRefusal", () => {
|
|
39
|
+
// The contract a page branches on is the (domain, reason) pair of ErrorInfo.
|
|
40
|
+
it("reads the domain and reason from the status details", () => {
|
|
41
|
+
expect(rpcRefusal(refused([info]))).toMatchObject({
|
|
42
|
+
code: "FAILED_PRECONDITION",
|
|
43
|
+
domain: "structured.id",
|
|
44
|
+
reason: "PASSWORD_REUSED",
|
|
45
|
+
});
|
|
46
|
+
expect(rpcReason(refused([info]))).toBe("PASSWORD_REUSED");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// A reason is defined only within its domain: the same name from another
|
|
50
|
+
// service (a proxied upstream, another edition) is not SID's reason, so a
|
|
51
|
+
// page must not branch on it as if it were.
|
|
52
|
+
it("reports a reason only for the domain asked about", () => {
|
|
53
|
+
const foreign = Any.pack(
|
|
54
|
+
ErrorInfo.create({
|
|
55
|
+
reason: "PASSWORD_REUSED",
|
|
56
|
+
domain: "upstream.example.com",
|
|
57
|
+
metadata: {},
|
|
58
|
+
}),
|
|
59
|
+
ErrorInfo,
|
|
60
|
+
);
|
|
61
|
+
expect(rpcReason(refused([foreign]))).toBeNull();
|
|
62
|
+
expect(rpcReason(refused([foreign]), "upstream.example.com")).toBe(
|
|
63
|
+
"PASSWORD_REUSED",
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// gRPC percent-encodes the status message on the wire; people read it decoded.
|
|
68
|
+
it("decodes the percent-encoded status message", () => {
|
|
69
|
+
expect(rpcRefusal(refused([]))?.message).toBe(
|
|
70
|
+
"this password was used before",
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("prefers the server's text for people", () => {
|
|
75
|
+
const localized = Any.pack(
|
|
76
|
+
LocalizedMessage.create({ locale: "en", message: "Choose another." }),
|
|
77
|
+
LocalizedMessage,
|
|
78
|
+
);
|
|
79
|
+
expect(refusalMessage(refused([info, localized]))).toBe("Choose another.");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// The status message is written for developers: people see the client's
|
|
83
|
+
// text for the reason, never the message.
|
|
84
|
+
it("shows the client's text for the reason", () => {
|
|
85
|
+
expect(refusalMessage(refused([info]))).toBe(
|
|
86
|
+
REFUSAL_TEXTS[ErrorReason.PASSWORD_REUSED],
|
|
87
|
+
);
|
|
88
|
+
expect(rpcRefusal(refused([info]))?.reasonCode).toBe(
|
|
89
|
+
ErrorReason.PASSWORD_REUSED,
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// A refusal without SID's reason (a transport failure, another domain, a
|
|
94
|
+
// reason this client does not know) has nothing for people but the
|
|
95
|
+
// caller's own context.
|
|
96
|
+
it("falls back to the caller's text without a known SID reason", () => {
|
|
97
|
+
expect(refusalMessage(refused([]), "Failed to load")).toBe(
|
|
98
|
+
"Failed to load",
|
|
99
|
+
);
|
|
100
|
+
const foreign = Any.pack(
|
|
101
|
+
ErrorInfo.create({
|
|
102
|
+
reason: "PASSWORD_REUSED",
|
|
103
|
+
domain: "upstream.example.com",
|
|
104
|
+
metadata: {},
|
|
105
|
+
}),
|
|
106
|
+
ErrorInfo,
|
|
107
|
+
);
|
|
108
|
+
expect(refusalMessage(refused([foreign]), "Failed to load")).toBe(
|
|
109
|
+
"Failed to load",
|
|
110
|
+
);
|
|
111
|
+
const unknown = Any.pack(
|
|
112
|
+
ErrorInfo.create({
|
|
113
|
+
reason: "NOT_YET_DEFINED",
|
|
114
|
+
domain: "structured.id",
|
|
115
|
+
metadata: {},
|
|
116
|
+
}),
|
|
117
|
+
ErrorInfo,
|
|
118
|
+
);
|
|
119
|
+
expect(rpcRefusal(refused([unknown]))?.reasonCode).toBeNull();
|
|
120
|
+
expect(refusalMessage(refused([unknown]), "Failed to load")).toBe(
|
|
121
|
+
"Failed to load",
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Every reason of the proto has a text: a reason added without one would
|
|
126
|
+
// reach people as the bare fallback.
|
|
127
|
+
it("has a text for every reason", () => {
|
|
128
|
+
const texts: Partial<Record<ErrorReason, string>> = REFUSAL_TEXTS;
|
|
129
|
+
for (const value of Object.values(ErrorReason)) {
|
|
130
|
+
if (typeof value !== "number") continue;
|
|
131
|
+
if (value === ErrorReason.ERROR_REASON_UNSPECIFIED) continue;
|
|
132
|
+
expect(texts[value]).toBeTruthy();
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("leaves any other error alone", () => {
|
|
137
|
+
expect(rpcRefusal(new Error("boom"))).toBeNull();
|
|
138
|
+
expect(rpcReason(new Error("boom"))).toBeNull();
|
|
139
|
+
expect(refusalMessage(new Error("boom"))).toBe("boom");
|
|
140
|
+
expect(refusalMessage("not an error", "Request failed")).toBe(
|
|
141
|
+
"Request failed",
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("survives an unreadable trailer", () => {
|
|
146
|
+
const err = new RpcError("m", "INTERNAL", {
|
|
147
|
+
"grpc-status-details-bin": "!!!",
|
|
148
|
+
});
|
|
149
|
+
expect(rpcRefusal(err)).toMatchObject({ reason: null, message: "m" });
|
|
150
|
+
});
|
|
151
|
+
});
|