@structured-id/ui-core 3.1.1 → 4.0.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 +1 -1
- package/src/composables/useBffAuth.test.ts +96 -0
- package/src/composables/useBffAuth.ts +34 -7
- package/src/index.ts +2 -0
- package/src/profile/components/SidRegistrationForm.test.ts +86 -0
- package/src/profile/components/SidRegistrationForm.vue +20 -14
- package/src/profile/composables/index.ts +1 -0
- package/src/profile/composables/useAuthApi.ts +7 -0
- package/src/profile/composables/usePasswordPolicy.test.ts +109 -0
- package/src/profile/composables/usePasswordPolicy.ts +83 -0
package/package.json
CHANGED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
bffCsrfToken,
|
|
4
|
+
bffLogout,
|
|
5
|
+
checkBffSession,
|
|
6
|
+
initBff,
|
|
7
|
+
} from "./useBffAuth";
|
|
8
|
+
|
|
9
|
+
/** A BFF that answers every request with this response. */
|
|
10
|
+
function bffAnswers(response: unknown) {
|
|
11
|
+
const fetchMock = vi.fn().mockResolvedValue(response);
|
|
12
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
13
|
+
return fetchMock;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The BFF's sign-out answer: the session ended, with this grant outcome. */
|
|
17
|
+
function ended(grant: string) {
|
|
18
|
+
return { ok: true, status: 200, json: async () => ({ grant }) };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("bffLogout", () => {
|
|
22
|
+
beforeEach(async () => {
|
|
23
|
+
initBff("");
|
|
24
|
+
bffAnswers({
|
|
25
|
+
ok: true,
|
|
26
|
+
status: 200,
|
|
27
|
+
json: async () => ({ sub: "s", csrf_token: "token-1" }),
|
|
28
|
+
});
|
|
29
|
+
await checkBffSession();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
vi.unstubAllGlobals();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// The page tells a local end of its session from one where the issuer also
|
|
37
|
+
// revoked the grant, so it can say which happened.
|
|
38
|
+
it.each(["revoked", "not_revoked", "none"] as const)(
|
|
39
|
+
"reports the ended session with grant %s",
|
|
40
|
+
async (grant) => {
|
|
41
|
+
const fetchMock = bffAnswers(ended(grant));
|
|
42
|
+
await expect(bffLogout()).resolves.toEqual({ ended: true, grant });
|
|
43
|
+
expect(fetchMock).toHaveBeenCalledWith("/auth/logout", {
|
|
44
|
+
method: "POST",
|
|
45
|
+
credentials: "include",
|
|
46
|
+
headers: { "X-CSRF-Token": "token-1" },
|
|
47
|
+
});
|
|
48
|
+
},
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
// A refused or failed sign-out leaves the BFF session alive: the page must
|
|
52
|
+
// not report it as ended.
|
|
53
|
+
it.each([
|
|
54
|
+
["refused", { ok: false, status: 403 }],
|
|
55
|
+
["unavailable", { ok: false, status: 503 }],
|
|
56
|
+
])("reports a %s sign-out as not ended", async (_name, response) => {
|
|
57
|
+
bffAnswers(response);
|
|
58
|
+
await expect(bffLogout()).resolves.toEqual({ ended: false });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("reports an unreachable BFF as not ended", async () => {
|
|
62
|
+
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("offline")));
|
|
63
|
+
await expect(bffLogout()).resolves.toEqual({ ended: false });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// An answer the page cannot read still came after the BFF ended the
|
|
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 () => {
|
|
83
|
+
vi.resetModules();
|
|
84
|
+
const fresh = await import("./useBffAuth");
|
|
85
|
+
const fetchMock = bffAnswers(ended("revoked"));
|
|
86
|
+
await expect(fresh.bffLogout()).resolves.toEqual({ ended: false });
|
|
87
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Whatever the BFF answers, the page forgets the session's token.
|
|
91
|
+
it("forgets the CSRF token even when the sign-out failed", async () => {
|
|
92
|
+
bffAnswers({ ok: false, status: 503 });
|
|
93
|
+
await bffLogout();
|
|
94
|
+
expect(bffCsrfToken()).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -118,28 +118,55 @@ export function startBffLogin(
|
|
|
118
118
|
window.location.href = url;
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* What became of the grant behind an ended BFF session: revoked by the
|
|
123
|
+
* issuer, left to expire because the issuer did not revoke it, none held,
|
|
124
|
+
* or unknown when the BFF's answer could not be read.
|
|
125
|
+
*/
|
|
126
|
+
export type BffGrantOutcome = "revoked" | "not_revoked" | "none" | "unknown";
|
|
127
|
+
|
|
128
|
+
/** The outcome of a sign-out: whether the BFF ended the session, and its grant. */
|
|
129
|
+
export type BffLogoutResult =
|
|
130
|
+
{ ended: true; grant: BffGrantOutcome } | { ended: false };
|
|
131
|
+
|
|
132
|
+
const GRANT_OUTCOMES: readonly BffGrantOutcome[] = [
|
|
133
|
+
"revoked",
|
|
134
|
+
"not_revoked",
|
|
135
|
+
"none",
|
|
136
|
+
];
|
|
137
|
+
|
|
121
138
|
/**
|
|
122
139
|
* Log out by calling POST /auth/logout with X-CSRF-Token header.
|
|
123
140
|
*
|
|
124
|
-
* The BFF validates the CSRF header,
|
|
125
|
-
*
|
|
126
|
-
*
|
|
141
|
+
* The BFF validates the CSRF header, ends the session, clears its cookie and
|
|
142
|
+
* says whether the issuer revoked the grant. `ended: false` means the
|
|
143
|
+
* session may still be alive (refused, unavailable, unreachable). The page
|
|
144
|
+
* forgets the CSRF token either way.
|
|
127
145
|
*
|
|
128
146
|
* @param bffBaseUrl Override BFF base URL
|
|
129
147
|
*/
|
|
130
|
-
export async function bffLogout(bffBaseUrl?: string): Promise<
|
|
148
|
+
export async function bffLogout(bffBaseUrl?: string): Promise<BffLogoutResult> {
|
|
131
149
|
const base = bffBaseUrl ?? _bffBaseUrl;
|
|
132
|
-
if (base === null) return;
|
|
150
|
+
if (base === null) return { ended: false };
|
|
133
151
|
const csrf = _csrfToken;
|
|
134
152
|
_csrfToken = null;
|
|
153
|
+
let resp: Response;
|
|
135
154
|
try {
|
|
136
|
-
await fetch(`${base}/auth/logout`, {
|
|
155
|
+
resp = await fetch(`${base}/auth/logout`, {
|
|
137
156
|
method: "POST",
|
|
138
157
|
credentials: "include",
|
|
139
158
|
headers: csrf ? { "X-CSRF-Token": csrf } : {},
|
|
140
159
|
});
|
|
141
160
|
} catch {
|
|
142
|
-
|
|
161
|
+
return { ended: false };
|
|
162
|
+
}
|
|
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" };
|
|
143
170
|
}
|
|
144
171
|
}
|
|
145
172
|
|
package/src/index.ts
CHANGED
|
@@ -297,6 +297,92 @@ describe("SidRegistrationForm", () => {
|
|
|
297
297
|
vi.useRealTimers();
|
|
298
298
|
});
|
|
299
299
|
|
|
300
|
+
describe("password rules", () => {
|
|
301
|
+
// A q-input that shows the first rule its value fails, as QInput does.
|
|
302
|
+
const ruleStubs = {
|
|
303
|
+
...quasarStubs,
|
|
304
|
+
"q-input": {
|
|
305
|
+
props: ["modelValue", "rules"],
|
|
306
|
+
emits: ["update:modelValue"],
|
|
307
|
+
computed: {
|
|
308
|
+
failed(): string {
|
|
309
|
+
const self = this as unknown as {
|
|
310
|
+
modelValue?: string;
|
|
311
|
+
rules?: ((v: string) => true | string)[];
|
|
312
|
+
};
|
|
313
|
+
for (const rule of self.rules ?? []) {
|
|
314
|
+
const result = rule(self.modelValue ?? "");
|
|
315
|
+
if (result !== true) return result;
|
|
316
|
+
}
|
|
317
|
+
return "";
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
template:
|
|
321
|
+
'<label><input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" /><span class="failed">{{ failed }}</span></label>',
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
const ceDefault = {
|
|
325
|
+
minLength: 8,
|
|
326
|
+
minUppercase: 1,
|
|
327
|
+
minLowercase: 1,
|
|
328
|
+
minDigit: 1,
|
|
329
|
+
minSymbol: 0,
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
async function passwordError(
|
|
333
|
+
value: string,
|
|
334
|
+
passwordPolicyFn: () => Promise<typeof ceDefault>,
|
|
335
|
+
): Promise<string> {
|
|
336
|
+
const w = mount(SidRegistrationForm, {
|
|
337
|
+
props: { registerFn, passwordPolicyFn, showConfirmPassword: false },
|
|
338
|
+
global: { stubs: ruleStubs },
|
|
339
|
+
});
|
|
340
|
+
await flushPromises();
|
|
341
|
+
const password = w.find('[data-test="password"]');
|
|
342
|
+
await password.find("input").setValue(value);
|
|
343
|
+
return password.find(".failed").text();
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// The rules are the server's policy: an 8-character password with an
|
|
347
|
+
// uppercase letter, a lowercase letter and a digit meets the CE default.
|
|
348
|
+
it("accepts a password that meets the server policy", async () => {
|
|
349
|
+
expect(
|
|
350
|
+
await passwordError("HrenVam1", () => Promise.resolve(ceDefault)),
|
|
351
|
+
).toBe("");
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// Each minimum names itself: a missing uppercase letter is not reported
|
|
355
|
+
// as a short password.
|
|
356
|
+
it("names the rule a password fails", async () => {
|
|
357
|
+
const policy = () => Promise.resolve(ceDefault);
|
|
358
|
+
expect(await passwordError("hrenvam1", policy)).toBe(
|
|
359
|
+
"At least 1 uppercase letter",
|
|
360
|
+
);
|
|
361
|
+
expect(await passwordError("HrenVamX", policy)).toBe("At least 1 digit");
|
|
362
|
+
expect(await passwordError("HrenVa1", policy)).toBe(
|
|
363
|
+
"At least 8 characters",
|
|
364
|
+
);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// Symbols are the printable ASCII characters that are not letters or
|
|
368
|
+
// digits, as the proof counts them; a space is not one.
|
|
369
|
+
it("checks the symbol minimum", async () => {
|
|
370
|
+
const policy = () => Promise.resolve({ ...ceDefault, minSymbol: 1 });
|
|
371
|
+
expect(await passwordError("Hren Vam1", policy)).toBe(
|
|
372
|
+
"At least 1 symbol",
|
|
373
|
+
);
|
|
374
|
+
expect(await passwordError("Hren-Vam1", policy)).toBe("");
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// Until the server answers only an empty password is refused: the
|
|
378
|
+
// proof, not the form, enforces the policy.
|
|
379
|
+
it("checks only presence until the policy is known", async () => {
|
|
380
|
+
const never = () => new Promise<typeof ceDefault>(() => {});
|
|
381
|
+
expect(await passwordError("a", never)).toBe("");
|
|
382
|
+
expect(await passwordError("", never)).toBe("This field is required");
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
300
386
|
it("renders footer slot", () => {
|
|
301
387
|
const w = mount(SidRegistrationForm, {
|
|
302
388
|
props: { registerFn },
|
|
@@ -217,6 +217,14 @@ import { ref, computed, watch, useSlots } from "vue";
|
|
|
217
217
|
import { SidPasswordCeremony, SidPrincipalInput } from "../../quasar";
|
|
218
218
|
import type { CeremonyOutcome } from "../../quasar";
|
|
219
219
|
import type { PasswordOperationProgress } from "../composables/useAuth";
|
|
220
|
+
import { getPasswordPolicy } from "../composables/useAuthApi";
|
|
221
|
+
import {
|
|
222
|
+
defaultPasswordRuleText,
|
|
223
|
+
failedPasswordRule,
|
|
224
|
+
usePasswordPolicy,
|
|
225
|
+
type PasswordPolicy,
|
|
226
|
+
type PasswordRuleText,
|
|
227
|
+
} from "../composables/usePasswordPolicy";
|
|
220
228
|
|
|
221
229
|
// Minimal QInput shape — we only use validate(). Avoids importing 'quasar' at
|
|
222
230
|
// type level (it's a peer dep, not present during ui-core typecheck).
|
|
@@ -254,8 +262,10 @@ const props = withDefaults(
|
|
|
254
262
|
confirmPasswordLabel?: string;
|
|
255
263
|
/** Password required validation text */
|
|
256
264
|
passwordRequiredText?: string;
|
|
257
|
-
/**
|
|
258
|
-
|
|
265
|
+
/** What a password that fails a policy minimum is told. */
|
|
266
|
+
passwordRuleText?: PasswordRuleText;
|
|
267
|
+
/** Loads the server's password policy. */
|
|
268
|
+
passwordPolicyFn?: () => Promise<PasswordPolicy>;
|
|
259
269
|
/** Passwords mismatch validation text */
|
|
260
270
|
passwordsMismatchText?: string;
|
|
261
271
|
/** Invite code input label */
|
|
@@ -303,8 +313,6 @@ const props = withDefaults(
|
|
|
303
313
|
) => Promise<void>;
|
|
304
314
|
/** What the ceremony says once the account is created. */
|
|
305
315
|
acceptedText?: string;
|
|
306
|
-
/** Min password length (default 12 — NIST SP 800-63B 4th draft baseline). */
|
|
307
|
-
passwordMinLength?: number;
|
|
308
316
|
/** Show confirm-password field with match validation. Default true. */
|
|
309
317
|
showConfirmPassword?: boolean;
|
|
310
318
|
/** Min username length. Registration default 6 (premium feature gating);
|
|
@@ -319,8 +327,8 @@ const props = withDefaults(
|
|
|
319
327
|
passwordLabel: "Password",
|
|
320
328
|
confirmPasswordLabel: "Confirm password",
|
|
321
329
|
passwordRequiredText: REQUIRED_FIELD_TEXT,
|
|
322
|
-
|
|
323
|
-
|
|
330
|
+
passwordRuleText: defaultPasswordRuleText,
|
|
331
|
+
passwordPolicyFn: getPasswordPolicy,
|
|
324
332
|
passwordsMismatchText: "Passwords do not match",
|
|
325
333
|
inviteCodeLabel: "Invite code",
|
|
326
334
|
inviteCodeRequiredText: "Invite code required",
|
|
@@ -380,16 +388,14 @@ let announce: (() => void) | undefined;
|
|
|
380
388
|
const passwordRef = ref<ValidatableInput | null>(null);
|
|
381
389
|
const confirmRef = ref<ValidatableInput | null>(null);
|
|
382
390
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
// to give immediate UX feedback.
|
|
391
|
+
const { policy } = usePasswordPolicy(props.passwordPolicyFn);
|
|
392
|
+
|
|
386
393
|
const passwordRules = computed(() => [
|
|
387
394
|
(v: string) => !!v || props.passwordRequiredText,
|
|
388
|
-
(v: string) =>
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
(v: string) => /[0-9]/.test(v) || props.passwordMinLengthText,
|
|
395
|
+
(v: string) => {
|
|
396
|
+
const failed = policy.value && failedPasswordRule(v, policy.value);
|
|
397
|
+
return !failed || props.passwordRuleText(failed.rule, failed.count);
|
|
398
|
+
},
|
|
393
399
|
]);
|
|
394
400
|
|
|
395
401
|
// Re-validate confirm field whenever password changes (eager match feedback).
|
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
type OpaqueZkppRegistrationFinishResponse,
|
|
58
58
|
type PasswordChangeChallengeResponse,
|
|
59
59
|
type PasswordChangeExecuteResponse,
|
|
60
|
+
type GetZkppPolicyResponse,
|
|
60
61
|
} from "@structured-id/proto/sid/v1/authn/auth";
|
|
61
62
|
import type {
|
|
62
63
|
PasswordHistoryContext,
|
|
@@ -574,6 +575,12 @@ export async function completePasswordReset(
|
|
|
574
575
|
return response;
|
|
575
576
|
}
|
|
576
577
|
|
|
578
|
+
/** The password policy the server's proof checks a new password against. */
|
|
579
|
+
export async function getPasswordPolicy(): Promise<GetZkppPolicyResponse> {
|
|
580
|
+
const { response } = await client().getZkppPolicy({});
|
|
581
|
+
return response;
|
|
582
|
+
}
|
|
583
|
+
|
|
577
584
|
// Recovery shard RPCs (downloadRecoveryShard, uploadRecoveryShard, getRecoveryInfo)
|
|
578
585
|
// are EE/SaaS-only — defined in proto-ee, not in CE proto.
|
|
579
586
|
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { flushPromises } from "@vue/test-utils";
|
|
3
|
+
import {
|
|
4
|
+
defaultPasswordRuleText,
|
|
5
|
+
failedPasswordRule,
|
|
6
|
+
usePasswordPolicy,
|
|
7
|
+
} from "./usePasswordPolicy";
|
|
8
|
+
|
|
9
|
+
const ceDefault = {
|
|
10
|
+
minLength: 8,
|
|
11
|
+
minUppercase: 1,
|
|
12
|
+
minLowercase: 1,
|
|
13
|
+
minDigit: 1,
|
|
14
|
+
minSymbol: 0,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
describe("failedPasswordRule", () => {
|
|
18
|
+
it("passes a password that meets every minimum", () => {
|
|
19
|
+
expect(failedPasswordRule("HrenVam1", ceDefault)).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// The first failed minimum is reported, in a fixed order, with the
|
|
23
|
+
// policy's number for it.
|
|
24
|
+
it("reports the first failed minimum with its number", () => {
|
|
25
|
+
expect(failedPasswordRule("hV1", ceDefault)).toEqual({
|
|
26
|
+
rule: "length",
|
|
27
|
+
count: 8,
|
|
28
|
+
});
|
|
29
|
+
expect(failedPasswordRule("hrenvam1", ceDefault)).toEqual({
|
|
30
|
+
rule: "uppercase",
|
|
31
|
+
count: 1,
|
|
32
|
+
});
|
|
33
|
+
expect(failedPasswordRule("HRENVAM1", ceDefault)).toEqual({
|
|
34
|
+
rule: "lowercase",
|
|
35
|
+
count: 1,
|
|
36
|
+
});
|
|
37
|
+
expect(failedPasswordRule("HrenVamX", ceDefault)).toEqual({
|
|
38
|
+
rule: "digit",
|
|
39
|
+
count: 1,
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Counts, not presence: a minimum of 2 needs two of the class.
|
|
44
|
+
it("counts the characters of a class", () => {
|
|
45
|
+
const policy = { ...ceDefault, minDigit: 2 };
|
|
46
|
+
expect(failedPasswordRule("HrenVam1", policy)?.rule).toBe("digit");
|
|
47
|
+
expect(failedPasswordRule("HrenVam12", policy)).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// The proof's classes are ASCII: a non-ASCII letter is none of them, a
|
|
51
|
+
// space is not a symbol, and every other printable ASCII non-alphanumeric
|
|
52
|
+
// character is.
|
|
53
|
+
it("classifies characters as the proof does", () => {
|
|
54
|
+
const policy = {
|
|
55
|
+
minLength: 0,
|
|
56
|
+
minUppercase: 0,
|
|
57
|
+
minLowercase: 0,
|
|
58
|
+
minDigit: 0,
|
|
59
|
+
minSymbol: 1,
|
|
60
|
+
};
|
|
61
|
+
expect(failedPasswordRule(" ", policy)?.rule).toBe("symbol");
|
|
62
|
+
expect(failedPasswordRule("Ж", policy)?.rule).toBe("symbol");
|
|
63
|
+
for (const symbol of "!/:@[`{~") {
|
|
64
|
+
expect(failedPasswordRule(symbol, policy)).toBeNull();
|
|
65
|
+
}
|
|
66
|
+
expect(
|
|
67
|
+
failedPasswordRule("ЖЖЖЖЖЖЖЖ", { ...ceDefault, minLength: 8 })?.rule,
|
|
68
|
+
).toBe("uppercase");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Length counts characters, so a character outside the BMP is one.
|
|
72
|
+
it("counts length in characters", () => {
|
|
73
|
+
const policy = { ...ceDefault, minUppercase: 0, minDigit: 0, minLength: 3 };
|
|
74
|
+
expect(failedPasswordRule("a😀b", policy)).toBeNull();
|
|
75
|
+
expect(failedPasswordRule("a😀", policy)?.rule).toBe("length");
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("defaultPasswordRuleText", () => {
|
|
80
|
+
it("names the rule and its number", () => {
|
|
81
|
+
expect(defaultPasswordRuleText("length", 8)).toBe("At least 8 characters");
|
|
82
|
+
expect(defaultPasswordRuleText("uppercase", 1)).toBe(
|
|
83
|
+
"At least 1 uppercase letter",
|
|
84
|
+
);
|
|
85
|
+
expect(defaultPasswordRuleText("lowercase", 2)).toBe(
|
|
86
|
+
"At least 2 lowercase letters",
|
|
87
|
+
);
|
|
88
|
+
expect(defaultPasswordRuleText("digit", 2)).toBe("At least 2 digits");
|
|
89
|
+
expect(defaultPasswordRuleText("symbol", 1)).toBe("At least 1 symbol");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("usePasswordPolicy", () => {
|
|
94
|
+
it("holds the policy once loaded", async () => {
|
|
95
|
+
const { policy } = usePasswordPolicy(() => Promise.resolve(ceDefault));
|
|
96
|
+
expect(policy.value).toBeNull();
|
|
97
|
+
await flushPromises();
|
|
98
|
+
expect(policy.value).toEqual(ceDefault);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// A policy that cannot be loaded leaves the checks to the server.
|
|
102
|
+
it("stays unknown when the policy cannot be loaded", async () => {
|
|
103
|
+
const { policy } = usePasswordPolicy(() =>
|
|
104
|
+
Promise.reject(new Error("unavailable")),
|
|
105
|
+
);
|
|
106
|
+
await flushPromises();
|
|
107
|
+
expect(policy.value).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The server's password policy, as a form checks a new password against it.
|
|
3
|
+
* The proof enforces the policy; these checks only tell the user early.
|
|
4
|
+
*/
|
|
5
|
+
import { readonly, ref } from "vue";
|
|
6
|
+
import type { GetZkppPolicyResponse } from "@structured-id/proto/sid/v1/authn/auth";
|
|
7
|
+
import { getPasswordPolicy } from "./useAuthApi";
|
|
8
|
+
|
|
9
|
+
/** The minimums of a password policy. */
|
|
10
|
+
export type PasswordPolicy = Pick<
|
|
11
|
+
GetZkppPolicyResponse,
|
|
12
|
+
"minLength" | "minUppercase" | "minLowercase" | "minDigit" | "minSymbol"
|
|
13
|
+
>;
|
|
14
|
+
|
|
15
|
+
/** A minimum a password can fail. */
|
|
16
|
+
export type PasswordRule =
|
|
17
|
+
"length" | "uppercase" | "lowercase" | "digit" | "symbol";
|
|
18
|
+
|
|
19
|
+
/** What a failed rule says, given the policy's number for it. */
|
|
20
|
+
export type PasswordRuleText = (rule: PasswordRule, count: number) => string;
|
|
21
|
+
|
|
22
|
+
export const defaultPasswordRuleText: PasswordRuleText = (rule, count) => {
|
|
23
|
+
switch (rule) {
|
|
24
|
+
case "length":
|
|
25
|
+
return `At least ${count} characters`;
|
|
26
|
+
case "uppercase":
|
|
27
|
+
return `At least ${count} uppercase ${count === 1 ? "letter" : "letters"}`;
|
|
28
|
+
case "lowercase":
|
|
29
|
+
return `At least ${count} lowercase ${count === 1 ? "letter" : "letters"}`;
|
|
30
|
+
case "digit":
|
|
31
|
+
return `At least ${count} ${count === 1 ? "digit" : "digits"}`;
|
|
32
|
+
case "symbol":
|
|
33
|
+
return `At least ${count} ${count === 1 ? "symbol" : "symbols"}`;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// The character classes the proof counts: ASCII letters and digits, and the
|
|
38
|
+
// printable ASCII characters that are neither (a space is not a symbol).
|
|
39
|
+
const UPPERCASE = /[A-Z]/g;
|
|
40
|
+
const LOWERCASE = /[a-z]/g;
|
|
41
|
+
const DIGIT = /[0-9]/g;
|
|
42
|
+
const SYMBOL = /[!-/:-@[-`{-~]/g;
|
|
43
|
+
|
|
44
|
+
const count = (value: string, pattern: RegExp) =>
|
|
45
|
+
value.match(pattern)?.length ?? 0;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The first minimum `password` fails, or null when it meets them all. Length
|
|
49
|
+
* is counted in characters: the proof counts UTF-8 bytes, never fewer, so a
|
|
50
|
+
* password that passes here is never refused there for its length.
|
|
51
|
+
*/
|
|
52
|
+
export function failedPasswordRule(
|
|
53
|
+
password: string,
|
|
54
|
+
policy: PasswordPolicy,
|
|
55
|
+
): { rule: PasswordRule; count: number } | null {
|
|
56
|
+
const checks: [PasswordRule, number, number][] = [
|
|
57
|
+
["length", [...password].length, policy.minLength],
|
|
58
|
+
["uppercase", count(password, UPPERCASE), policy.minUppercase],
|
|
59
|
+
["lowercase", count(password, LOWERCASE), policy.minLowercase],
|
|
60
|
+
["digit", count(password, DIGIT), policy.minDigit],
|
|
61
|
+
["symbol", count(password, SYMBOL), policy.minSymbol],
|
|
62
|
+
];
|
|
63
|
+
for (const [rule, has, needs] of checks) {
|
|
64
|
+
if (has < needs) return { rule, count: needs };
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Load the policy once. Until it is known (or when it cannot be loaded)
|
|
71
|
+
* `policy` stays null: the operation itself then meets the server, which
|
|
72
|
+
* refuses a password its policy does not admit.
|
|
73
|
+
*/
|
|
74
|
+
export function usePasswordPolicy(
|
|
75
|
+
load: () => Promise<PasswordPolicy> = getPasswordPolicy,
|
|
76
|
+
) {
|
|
77
|
+
const policy = ref<PasswordPolicy | null>(null);
|
|
78
|
+
load().then(
|
|
79
|
+
(p) => (policy.value = p),
|
|
80
|
+
() => undefined,
|
|
81
|
+
);
|
|
82
|
+
return { policy: readonly(policy) };
|
|
83
|
+
}
|