@udibo/oauth2 0.9.2 → 0.11.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/esm/testing/contract/identity-session.d.ts +54 -0
- package/esm/testing/contract/identity-session.js +165 -0
- package/esm/testing/contract/identity-user-store.d.ts +52 -0
- package/esm/testing/contract/identity-user-store.js +176 -0
- package/esm/testing/contract/mod.d.ts +7 -1
- package/esm/testing/contract/mod.js +6 -0
- package/esm/testing/contract/tenant.d.ts +44 -1
- package/esm/testing/contract/tenant.js +253 -1
- package/esm/testing/mod.d.ts +8 -6
- package/esm/testing/mod.js +7 -5
- package/esm/testing/tenant.d.ts +55 -4
- package/esm/testing/tenant.js +190 -17
- package/package.json +1 -1
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ListableSessionService, RevocableSessionService, SessionSummary } from "../../identity/session.js";
|
|
2
|
+
/** Session states the application translates into its own storage fields. */
|
|
3
|
+
export type IdentitySessionContractState = "live" | "revoked" | "expired" | "idle-timed-out";
|
|
4
|
+
/** Isolated revocation implementation and app-owned session setup helpers. */
|
|
5
|
+
export interface RevocableSessionServiceContractFixture {
|
|
6
|
+
/** Implementation under test. */
|
|
7
|
+
service: RevocableSessionService;
|
|
8
|
+
/** Two distinct existing owners, using your application's valid user-id format. */
|
|
9
|
+
userId: string;
|
|
10
|
+
/** Existing owner whose sessions must survive operations on userId. */
|
|
11
|
+
otherUserId: string;
|
|
12
|
+
/** Stable, distinct app-valid non-secret session ids for sequences 1 through 7 and 99. */
|
|
13
|
+
sessionId(sequence: number): string;
|
|
14
|
+
/** Seed one unique session with the indicated owner and state. */
|
|
15
|
+
addSession(userId: string, summary: SessionSummary, state: IdentitySessionContractState): Promise<void>;
|
|
16
|
+
/** Whether the application would still accept this session after revocation returns. */
|
|
17
|
+
isLive(sessionId: string): Promise<boolean>;
|
|
18
|
+
/** Release resources and remove this test's records. */
|
|
19
|
+
dispose?(): Promise<void> | void;
|
|
20
|
+
}
|
|
21
|
+
/** Options for runRevocableSessionServiceContractTests. */
|
|
22
|
+
export interface RevocableSessionServiceContractOptions {
|
|
23
|
+
/** Create a fresh empty fixture for each test. */
|
|
24
|
+
makeFixture(): RevocableSessionServiceContractFixture | Promise<RevocableSessionServiceContractFixture>;
|
|
25
|
+
/** Override the outer suite name. */
|
|
26
|
+
describeName?: string;
|
|
27
|
+
}
|
|
28
|
+
/** Fixture for a store that exposes authoritative session display lists. */
|
|
29
|
+
export interface ListableSessionServiceContractFixture extends RevocableSessionServiceContractFixture {
|
|
30
|
+
/** Both listing and immediate revocation must address the same stored sessions. */
|
|
31
|
+
service: RevocableSessionService & ListableSessionService;
|
|
32
|
+
}
|
|
33
|
+
/** Options for runListableSessionServiceContractTests. */
|
|
34
|
+
export interface ListableSessionServiceContractOptions {
|
|
35
|
+
/** Create a fresh empty fixture; translate ended states into your expiry and idle policy. */
|
|
36
|
+
makeFixture(): ListableSessionServiceContractFixture | Promise<ListableSessionServiceContractFixture>;
|
|
37
|
+
/** Override the outer suite name. */
|
|
38
|
+
describeName?: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Register checks for user-scoped, immediately effective session revocation.
|
|
42
|
+
* The count checks seed live sessions only; they impose no retention policy on
|
|
43
|
+
* ended rows. Concurrent calls are drained before fixture cleanup. Their
|
|
44
|
+
* counts must reflect sessions actually revoked rather than double-counting
|
|
45
|
+
* the same live sessions. This tests observed interleavings, not all races.
|
|
46
|
+
*/
|
|
47
|
+
export declare function runRevocableSessionServiceContractTests(options: RevocableSessionServiceContractOptions): void;
|
|
48
|
+
/**
|
|
49
|
+
* Register checks for user-scoped, newest-activity-first session display lists.
|
|
50
|
+
* Verifies base timestamps and only the documented display keys, excluding
|
|
51
|
+
* token material and secrets. Optional coarse client metadata may be omitted.
|
|
52
|
+
* Run the separate revocation suite as well to verify its complete contract.
|
|
53
|
+
*/
|
|
54
|
+
export declare function runListableSessionServiceContractTests(options: ListableSessionServiceContractOptions): void;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract checks for app-owned identity-session revocation and display lists.
|
|
3
|
+
* These are distinct from the Hono BFF SessionStore contract.
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
import { assert, assertEquals, assertExists, assertStrictEquals, } from "../../deps/jsr.io/@std/assert/1.0.19/mod.js";
|
|
7
|
+
import { afterEach, beforeEach, describe, it } from "../../deps/jsr.io/@std/testing/1.0.20/bdd.js";
|
|
8
|
+
function summary(id, lastSeenAt = Date.now() - 30_000) {
|
|
9
|
+
return {
|
|
10
|
+
id,
|
|
11
|
+
createdAt: new Date(Date.now() - 60_000),
|
|
12
|
+
lastSeenAt: new Date(lastSeenAt),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Register checks for user-scoped, immediately effective session revocation.
|
|
17
|
+
* The count checks seed live sessions only; they impose no retention policy on
|
|
18
|
+
* ended rows. Concurrent calls are drained before fixture cleanup. Their
|
|
19
|
+
* counts must reflect sessions actually revoked rather than double-counting
|
|
20
|
+
* the same live sessions. This tests observed interleavings, not all races.
|
|
21
|
+
*/
|
|
22
|
+
export function runRevocableSessionServiceContractTests(options) {
|
|
23
|
+
describe(options.describeName ?? "RevocableSessionService contract", () => {
|
|
24
|
+
let fixture;
|
|
25
|
+
beforeEach(async () => {
|
|
26
|
+
fixture = await options.makeFixture();
|
|
27
|
+
assert(fixture.userId !== fixture.otherUserId, "session fixture owners must be distinct");
|
|
28
|
+
});
|
|
29
|
+
afterEach(async () => {
|
|
30
|
+
await fixture?.dispose?.();
|
|
31
|
+
});
|
|
32
|
+
async function seed() {
|
|
33
|
+
for (const id of [
|
|
34
|
+
fixture.sessionId(1),
|
|
35
|
+
fixture.sessionId(2),
|
|
36
|
+
fixture.sessionId(3),
|
|
37
|
+
]) {
|
|
38
|
+
await fixture.addSession(fixture.userId, summary(id), "live");
|
|
39
|
+
}
|
|
40
|
+
await fixture.addSession(fixture.otherUserId, summary(fixture.sessionId(4)), "live");
|
|
41
|
+
}
|
|
42
|
+
it("reports zero for users without sessions", async () => {
|
|
43
|
+
assertEquals(await fixture.service.revokeAllByUser(fixture.userId), 0);
|
|
44
|
+
assertEquals(await fixture.service.revokeOthers(fixture.userId, fixture.sessionId(99)), 0);
|
|
45
|
+
});
|
|
46
|
+
it("revokes all of the selected user's sessions immediately and only once", async () => {
|
|
47
|
+
await seed();
|
|
48
|
+
assertEquals(await fixture.service.revokeAllByUser(fixture.userId), 3);
|
|
49
|
+
for (const id of [
|
|
50
|
+
fixture.sessionId(1),
|
|
51
|
+
fixture.sessionId(2),
|
|
52
|
+
fixture.sessionId(3),
|
|
53
|
+
]) {
|
|
54
|
+
assertStrictEquals(await fixture.isLive(id), false);
|
|
55
|
+
}
|
|
56
|
+
assertStrictEquals(await fixture.isLive(fixture.sessionId(4)), true);
|
|
57
|
+
assertEquals(await fixture.service.revokeAllByUser(fixture.userId), 0);
|
|
58
|
+
});
|
|
59
|
+
it("keeps only the selected current session and preserves another user's sessions", async () => {
|
|
60
|
+
await seed();
|
|
61
|
+
assertEquals(await fixture.service.revokeOthers(fixture.userId, fixture.sessionId(2)), 2);
|
|
62
|
+
assertStrictEquals(await fixture.isLive(fixture.sessionId(1)), false);
|
|
63
|
+
assertStrictEquals(await fixture.isLive(fixture.sessionId(3)), false);
|
|
64
|
+
assertStrictEquals(await fixture.isLive(fixture.sessionId(2)), true);
|
|
65
|
+
assertStrictEquals(await fixture.isLive(fixture.sessionId(4)), true);
|
|
66
|
+
assertEquals(await fixture.service.revokeOthers(fixture.userId, fixture.sessionId(2)), 0);
|
|
67
|
+
});
|
|
68
|
+
it("a foreign keepSessionId cannot exempt the selected user's sessions", async () => {
|
|
69
|
+
await seed();
|
|
70
|
+
assertEquals(await fixture.service.revokeOthers(fixture.userId, fixture.sessionId(4)), 3);
|
|
71
|
+
for (const id of [
|
|
72
|
+
fixture.sessionId(1),
|
|
73
|
+
fixture.sessionId(2),
|
|
74
|
+
fixture.sessionId(3),
|
|
75
|
+
]) {
|
|
76
|
+
assertStrictEquals(await fixture.isLive(id), false);
|
|
77
|
+
}
|
|
78
|
+
assertStrictEquals(await fixture.isLive(fixture.sessionId(4)), true);
|
|
79
|
+
});
|
|
80
|
+
it("does not double-count concurrent revocations", async () => {
|
|
81
|
+
await seed();
|
|
82
|
+
const results = await Promise.allSettled(Array.from({ length: 8 }, () => fixture.service.revokeAllByUser(fixture.userId)));
|
|
83
|
+
const counts = results.map((result) => {
|
|
84
|
+
if (result.status === "rejected")
|
|
85
|
+
throw result.reason;
|
|
86
|
+
return result.value;
|
|
87
|
+
});
|
|
88
|
+
assertEquals(counts.reduce((sum, count) => sum + count, 0), 3, "concurrent revocation counts must not count a live session twice");
|
|
89
|
+
for (const id of [
|
|
90
|
+
fixture.sessionId(1),
|
|
91
|
+
fixture.sessionId(2),
|
|
92
|
+
fixture.sessionId(3),
|
|
93
|
+
]) {
|
|
94
|
+
assertStrictEquals(await fixture.isLive(id), false);
|
|
95
|
+
}
|
|
96
|
+
assertStrictEquals(await fixture.isLive(fixture.sessionId(4)), true);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Register checks for user-scoped, newest-activity-first session display lists.
|
|
102
|
+
* Verifies base timestamps and only the documented display keys, excluding
|
|
103
|
+
* token material and secrets. Optional coarse client metadata may be omitted.
|
|
104
|
+
* Run the separate revocation suite as well to verify its complete contract.
|
|
105
|
+
*/
|
|
106
|
+
export function runListableSessionServiceContractTests(options) {
|
|
107
|
+
describe(options.describeName ?? "ListableSessionService contract", () => {
|
|
108
|
+
let fixture;
|
|
109
|
+
beforeEach(async () => {
|
|
110
|
+
fixture = await options.makeFixture();
|
|
111
|
+
assert(fixture.userId !== fixture.otherUserId, "session fixture owners must be distinct");
|
|
112
|
+
});
|
|
113
|
+
afterEach(async () => {
|
|
114
|
+
await fixture?.dispose?.();
|
|
115
|
+
});
|
|
116
|
+
it("returns an empty list for a user without live sessions", async () => {
|
|
117
|
+
assertEquals(await fixture.service.listByUser(fixture.userId), []);
|
|
118
|
+
});
|
|
119
|
+
it("lists only the owner's live sessions in most-recently-active order", async () => {
|
|
120
|
+
const oldest = summary(fixture.sessionId(1));
|
|
121
|
+
const newest = {
|
|
122
|
+
...summary(fixture.sessionId(2), Date.now() - 1_000),
|
|
123
|
+
userAgent: "contract-browser",
|
|
124
|
+
location: "Example City, US",
|
|
125
|
+
};
|
|
126
|
+
const middle = summary(fixture.sessionId(3), Date.now() - 2_000);
|
|
127
|
+
for (const value of [oldest, newest, middle]) {
|
|
128
|
+
await fixture.addSession(fixture.userId, value, "live");
|
|
129
|
+
}
|
|
130
|
+
for (const [index, state] of ["revoked", "expired", "idle-timed-out"].entries()) {
|
|
131
|
+
await fixture.addSession(fixture.userId, summary(fixture.sessionId(index + 5)), state);
|
|
132
|
+
}
|
|
133
|
+
await fixture.addSession(fixture.otherUserId, summary(fixture.sessionId(4)), "live");
|
|
134
|
+
const rows = await fixture.service.listByUser(fixture.userId);
|
|
135
|
+
assertEquals(rows.map((row) => row.id), [fixture.sessionId(2), fixture.sessionId(3), fixture.sessionId(1)], "lists must exclude foreign and ended sessions and sort by last activity");
|
|
136
|
+
const expectedRows = [newest, middle, oldest];
|
|
137
|
+
for (const [index, expected] of expectedRows.entries()) {
|
|
138
|
+
const row = rows[index];
|
|
139
|
+
assertExists(row);
|
|
140
|
+
assertEquals(row.createdAt, expected.createdAt);
|
|
141
|
+
assertEquals(row.lastSeenAt, expected.lastSeenAt);
|
|
142
|
+
assertEquals(Object.keys(row).filter((key) => !["id", "createdAt", "lastSeenAt", "userAgent", "location"]
|
|
143
|
+
.includes(key)), [], "session summaries must not expose secret/hash or token material");
|
|
144
|
+
if (row.userAgent !== undefined) {
|
|
145
|
+
assertEquals(row.userAgent, expected.userAgent);
|
|
146
|
+
}
|
|
147
|
+
if (row.location !== undefined) {
|
|
148
|
+
assertEquals(row.location, expected.location);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
assertEquals((await fixture.service.listByUser(fixture.otherUserId)).map((row) => row.id), [fixture.sessionId(4)]);
|
|
152
|
+
});
|
|
153
|
+
it("removes revoked sessions from the authoritative list immediately", async () => {
|
|
154
|
+
await fixture.addSession(fixture.userId, summary(fixture.sessionId(1)), "live");
|
|
155
|
+
await fixture.addSession(fixture.userId, summary(fixture.sessionId(2)), "live");
|
|
156
|
+
await fixture.addSession(fixture.otherUserId, summary(fixture.sessionId(4)), "live");
|
|
157
|
+
assertEquals(await fixture.service.revokeOthers(fixture.userId, fixture.sessionId(1)), 1);
|
|
158
|
+
assertEquals((await fixture.service.listByUser(fixture.userId)).map((row) => row.id), [fixture.sessionId(1)]);
|
|
159
|
+
assertStrictEquals(await fixture.isLive(fixture.sessionId(2)), false);
|
|
160
|
+
assertEquals(await fixture.service.revokeAllByUser(fixture.userId), 1);
|
|
161
|
+
assertEquals(await fixture.service.listByUser(fixture.userId), []);
|
|
162
|
+
assertEquals((await fixture.service.listByUser(fixture.otherUserId)).map((row) => row.id), [fixture.sessionId(4)]);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { IdentityUser, IdentityUserStore } from "../../identity/service.js";
|
|
2
|
+
/** App-specific profile and lookup keys for one fresh test user. */
|
|
3
|
+
export interface IdentityUserContractProfile {
|
|
4
|
+
/** Valid sign-up fields for your application. */
|
|
5
|
+
profile: Record<string, unknown>;
|
|
6
|
+
/** Identifier that findByIdentifier must resolve for this profile. */
|
|
7
|
+
identifier: string;
|
|
8
|
+
/** Email that findByEmail must resolve for this profile. */
|
|
9
|
+
email: string;
|
|
10
|
+
}
|
|
11
|
+
/** Isolated store and app-owned setup helpers for one test. */
|
|
12
|
+
export interface IdentityUserStoreContractFixture<User extends IdentityUser> {
|
|
13
|
+
/** Fresh, empty implementation being verified. */
|
|
14
|
+
store: IdentityUserStore<User>;
|
|
15
|
+
/** Valid app-shaped user id that is absent from this fixture. */
|
|
16
|
+
unknownUserId: string;
|
|
17
|
+
/** Distinct valid profiles; return the same profile for the same sequence (1 or 2). */
|
|
18
|
+
makeProfile(sequence: number): IdentityUserContractProfile;
|
|
19
|
+
/** Remove a native credential to exercise compare-and-set from undefined. */
|
|
20
|
+
removeCredential?(userId: string): Promise<void>;
|
|
21
|
+
/** Change the current email without verifying it. Required for emailVerification. */
|
|
22
|
+
setEmail?(userId: string, email: string): Promise<void>;
|
|
23
|
+
/** Read the persisted verification flag. Required for emailVerification. */
|
|
24
|
+
isEmailVerified?(userId: string): Promise<boolean>;
|
|
25
|
+
/** Seed an imported hash. Required for legacyCredentials. */
|
|
26
|
+
setLegacyCredential?(userId: string, hash: string): Promise<void>;
|
|
27
|
+
/** Release resources and remove this test's records after all calls finish. */
|
|
28
|
+
dispose?(): Promise<void> | void;
|
|
29
|
+
}
|
|
30
|
+
/** Options for runIdentityUserStoreContractTests. */
|
|
31
|
+
export interface IdentityUserStoreContractOptions<User extends IdentityUser> {
|
|
32
|
+
/** Create an isolated fixture for each test. */
|
|
33
|
+
makeFixture(): IdentityUserStoreContractFixture<User> | Promise<IdentityUserStoreContractFixture<User>>;
|
|
34
|
+
/** Override the outer suite's name. */
|
|
35
|
+
describeName?: string;
|
|
36
|
+
/** Require the optional atomic credential-replacement capability and its setup helper. */
|
|
37
|
+
replaceCredential?: boolean;
|
|
38
|
+
/** Require guarded, idempotent email verification and its observation helpers. */
|
|
39
|
+
emailVerification?: boolean;
|
|
40
|
+
/** Require imported credential lookup and clearing, plus its setup helper. */
|
|
41
|
+
legacyCredentials?: boolean;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Register the IdentityUserStore suite in a Deno test file. Credential fixtures
|
|
45
|
+
* are serialized values, not real passwords: these checks verify persistence,
|
|
46
|
+
* lookup, and conditional writes, rather than password hashing. Concurrent
|
|
47
|
+
* checks can expose read-then-write races but cannot prove every interleaving.
|
|
48
|
+
* Enable each optional capability your application uses; disabled capabilities
|
|
49
|
+
* are not verified. Application validation and identifier normalization remain
|
|
50
|
+
* app-owned and are outside this suite.
|
|
51
|
+
*/
|
|
52
|
+
export declare function runIdentityUserStoreContractTests<User extends IdentityUser>(options: IdentityUserStoreContractOptions<User>): void;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reusable checks for app-owned identity users and password credentials.
|
|
3
|
+
* Optional capabilities are selected explicitly so an omitted implementation
|
|
4
|
+
* cannot silently pass a capability the application intends to provide.
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
import { assert, assertEquals, assertExists, assertStrictEquals, } from "../../deps/jsr.io/@std/assert/1.0.19/mod.js";
|
|
8
|
+
import { afterEach, beforeEach, describe, it } from "../../deps/jsr.io/@std/testing/1.0.20/bdd.js";
|
|
9
|
+
const ORIGINAL = {
|
|
10
|
+
hash: "11".repeat(32),
|
|
11
|
+
salt: "22".repeat(16),
|
|
12
|
+
params: { algorithm: "pbkdf2-sha256", iterations: 600_000 },
|
|
13
|
+
};
|
|
14
|
+
const REPLACEMENT = {
|
|
15
|
+
hash: "33".repeat(32),
|
|
16
|
+
salt: "44".repeat(16),
|
|
17
|
+
params: { algorithm: "pbkdf2-sha256", iterations: 700_000 },
|
|
18
|
+
};
|
|
19
|
+
async function drain(calls) {
|
|
20
|
+
const results = await Promise.allSettled(calls);
|
|
21
|
+
return results.map((result) => {
|
|
22
|
+
if (result.status === "rejected")
|
|
23
|
+
throw result.reason;
|
|
24
|
+
return result.value;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Register the IdentityUserStore suite in a Deno test file. Credential fixtures
|
|
29
|
+
* are serialized values, not real passwords: these checks verify persistence,
|
|
30
|
+
* lookup, and conditional writes, rather than password hashing. Concurrent
|
|
31
|
+
* checks can expose read-then-write races but cannot prove every interleaving.
|
|
32
|
+
* Enable each optional capability your application uses; disabled capabilities
|
|
33
|
+
* are not verified. Application validation and identifier normalization remain
|
|
34
|
+
* app-owned and are outside this suite.
|
|
35
|
+
*/
|
|
36
|
+
export function runIdentityUserStoreContractTests(options) {
|
|
37
|
+
describe(options.describeName ?? "IdentityUserStore contract", () => {
|
|
38
|
+
let fixture;
|
|
39
|
+
let store;
|
|
40
|
+
beforeEach(async () => {
|
|
41
|
+
fixture = await options.makeFixture();
|
|
42
|
+
store = fixture.store;
|
|
43
|
+
});
|
|
44
|
+
afterEach(async () => {
|
|
45
|
+
await fixture?.dispose?.();
|
|
46
|
+
});
|
|
47
|
+
async function create(sequence = 1) {
|
|
48
|
+
return await store.create(fixture.makeProfile(sequence).profile, structuredClone(ORIGINAL));
|
|
49
|
+
}
|
|
50
|
+
it("returns undefined for unknown lookup keys and credentials", async () => {
|
|
51
|
+
assertStrictEquals(await store.findByIdentifier("missing-identity-contract@example.invalid"), undefined);
|
|
52
|
+
assertStrictEquals(await store.findByEmail("missing-identity-contract@example.invalid"), undefined);
|
|
53
|
+
assertStrictEquals(await store.getCredential(fixture.unknownUserId), undefined);
|
|
54
|
+
});
|
|
55
|
+
it("creates distinct users and resolves each profile's lookup keys", async () => {
|
|
56
|
+
const first = await create();
|
|
57
|
+
const second = await create(2);
|
|
58
|
+
assert(first.id.length > 0);
|
|
59
|
+
assert(second.id.length > 0);
|
|
60
|
+
assert(first.id !== second.id, "distinct users must have distinct ids");
|
|
61
|
+
for (const [sequence, user] of [[1, first], [2, second]]) {
|
|
62
|
+
const profile = fixture.makeProfile(sequence);
|
|
63
|
+
assertEquals((await store.findByIdentifier(profile.identifier))?.id, user.id);
|
|
64
|
+
assertEquals((await store.findByEmail(profile.email))?.id, user.id);
|
|
65
|
+
assertEquals(await store.getCredential(user.id), ORIGINAL);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
it("replaces every credential field without changing another user", async () => {
|
|
69
|
+
const first = await create();
|
|
70
|
+
const second = await create(2);
|
|
71
|
+
await store.setCredential(first.id, structuredClone(REPLACEMENT));
|
|
72
|
+
assertEquals(await store.getCredential(first.id), REPLACEMENT);
|
|
73
|
+
assertEquals(await store.getCredential(second.id), ORIGINAL);
|
|
74
|
+
await store.setCredential(first.id, {
|
|
75
|
+
hash: ORIGINAL.hash,
|
|
76
|
+
salt: ORIGINAL.salt,
|
|
77
|
+
});
|
|
78
|
+
assertEquals(await store.getCredential(first.id), {
|
|
79
|
+
hash: ORIGINAL.hash,
|
|
80
|
+
salt: ORIGINAL.salt,
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
if (options.replaceCredential) {
|
|
84
|
+
it("compares credential values, including every parameter and absent params", async () => {
|
|
85
|
+
const user = await create();
|
|
86
|
+
assertExists(store.replaceCredential, "replaceCredential capability is required");
|
|
87
|
+
const mismatches = [
|
|
88
|
+
{ ...ORIGINAL, hash: REPLACEMENT.hash },
|
|
89
|
+
{ ...ORIGINAL, salt: REPLACEMENT.salt },
|
|
90
|
+
{ ...ORIGINAL, params: { algorithm: "other", iterations: 600_000 } },
|
|
91
|
+
{
|
|
92
|
+
...ORIGINAL,
|
|
93
|
+
params: { algorithm: "pbkdf2-sha256", iterations: 600_001 },
|
|
94
|
+
},
|
|
95
|
+
{ hash: ORIGINAL.hash, salt: ORIGINAL.salt },
|
|
96
|
+
];
|
|
97
|
+
for (const expected of mismatches) {
|
|
98
|
+
assertStrictEquals(await store.replaceCredential(user.id, expected, REPLACEMENT), false);
|
|
99
|
+
assertEquals(await store.getCredential(user.id), ORIGINAL, "a failed compare-and-set must preserve the winner");
|
|
100
|
+
}
|
|
101
|
+
assertStrictEquals(await store.replaceCredential(user.id, structuredClone(ORIGINAL), REPLACEMENT), true);
|
|
102
|
+
assertEquals(await store.getCredential(user.id), REPLACEMENT);
|
|
103
|
+
assertStrictEquals(await store.replaceCredential(user.id, ORIGINAL, ORIGINAL), false);
|
|
104
|
+
assertEquals(await store.getCredential(user.id), REPLACEMENT, "read after a lost compare-and-set must see the committed credential");
|
|
105
|
+
await store.setCredential(user.id, {
|
|
106
|
+
hash: ORIGINAL.hash,
|
|
107
|
+
salt: ORIGINAL.salt,
|
|
108
|
+
});
|
|
109
|
+
assertStrictEquals(await store.replaceCredential(user.id, {
|
|
110
|
+
hash: ORIGINAL.hash,
|
|
111
|
+
salt: ORIGINAL.salt,
|
|
112
|
+
}, REPLACEMENT), true);
|
|
113
|
+
});
|
|
114
|
+
it("upgrades an absent credential only once", async () => {
|
|
115
|
+
const user = await create();
|
|
116
|
+
assertExists(store.replaceCredential);
|
|
117
|
+
assertExists(fixture.removeCredential, "removeCredential setup helper is required");
|
|
118
|
+
await fixture.removeCredential(user.id);
|
|
119
|
+
assertStrictEquals(await store.getCredential(user.id), undefined);
|
|
120
|
+
const results = await drain(Array.from({ length: 8 }, () => store.replaceCredential(user.id, undefined, structuredClone(REPLACEMENT))));
|
|
121
|
+
assertEquals(results.filter(Boolean).length, 1, "exactly one concurrent imported upgrade may win");
|
|
122
|
+
assertEquals(await store.getCredential(user.id), REPLACEMENT);
|
|
123
|
+
});
|
|
124
|
+
it("allows exactly one concurrent replacement of the same credential", async () => {
|
|
125
|
+
const user = await create();
|
|
126
|
+
const other = await create(2);
|
|
127
|
+
assertExists(store.replaceCredential);
|
|
128
|
+
const candidates = Array.from({ length: 8 }, (_, index) => ({
|
|
129
|
+
...REPLACEMENT,
|
|
130
|
+
hash: (index + 5).toString(16).padStart(2, "0").repeat(32),
|
|
131
|
+
}));
|
|
132
|
+
const results = await drain(candidates.map((credential) => store.replaceCredential(user.id, structuredClone(ORIGINAL), credential)));
|
|
133
|
+
assertEquals(results.filter(Boolean).length, 1, "exactly one concurrent replacement may win");
|
|
134
|
+
assertEquals(await store.getCredential(user.id), candidates[results.indexOf(true)]);
|
|
135
|
+
assertEquals(await store.getCredential(other.id), ORIGINAL);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
if (options.emailVerification) {
|
|
139
|
+
it("verifies only the issued-for email and is idempotent", async () => {
|
|
140
|
+
const user = await create();
|
|
141
|
+
const other = await create(2);
|
|
142
|
+
assertExists(store.markEmailVerified, "email verification capability is required");
|
|
143
|
+
assertExists(fixture.setEmail);
|
|
144
|
+
assertExists(fixture.isEmailVerified);
|
|
145
|
+
const originalEmail = fixture.makeProfile(1).email;
|
|
146
|
+
const changedEmail = "changed-identity-contract@example.invalid";
|
|
147
|
+
assertStrictEquals(await fixture.isEmailVerified(user.id), false);
|
|
148
|
+
await fixture.setEmail(user.id, changedEmail);
|
|
149
|
+
await store.markEmailVerified(user.id, originalEmail);
|
|
150
|
+
assertStrictEquals(await fixture.isEmailVerified(user.id), false, "a token for an old email must not verify the new email");
|
|
151
|
+
await store.markEmailVerified(user.id, changedEmail);
|
|
152
|
+
await store.markEmailVerified(user.id, changedEmail);
|
|
153
|
+
assertStrictEquals(await fixture.isEmailVerified(user.id), true);
|
|
154
|
+
assertStrictEquals(await fixture.isEmailVerified(other.id), false);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
if (options.legacyCredentials) {
|
|
158
|
+
it("clears the selected imported credential and is idempotent", async () => {
|
|
159
|
+
const user = await create();
|
|
160
|
+
const other = await create(2);
|
|
161
|
+
assertExists(store.getLegacyCredential, "legacy credential lookup capability is required");
|
|
162
|
+
assertExists(store.clearLegacyCredential, "legacy credential clearing capability is required");
|
|
163
|
+
assertExists(fixture.setLegacyCredential);
|
|
164
|
+
assertStrictEquals(await store.getLegacyCredential(user.id), null);
|
|
165
|
+
await fixture.setLegacyCredential(user.id, "imported-hash-one");
|
|
166
|
+
await fixture.setLegacyCredential(other.id, "imported-hash-two");
|
|
167
|
+
assertEquals(await store.getLegacyCredential(user.id), "imported-hash-one");
|
|
168
|
+
await store.clearLegacyCredential(user.id);
|
|
169
|
+
await store.clearLegacyCredential(user.id);
|
|
170
|
+
assertStrictEquals(await store.getLegacyCredential(user.id), null);
|
|
171
|
+
assertEquals(await store.getLegacyCredential(other.id), "imported-hash-two");
|
|
172
|
+
assertEquals(await store.getCredential(user.id), ORIGINAL);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
}
|
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
* sequential case and fails the concurrent one, which is the whole
|
|
21
21
|
* reason the suites exist.
|
|
22
22
|
*
|
|
23
|
+
* Identity user storage, session revocation, and authoritative session listing
|
|
24
|
+
* also have separate suites. They use app-owned fixture helpers to verify
|
|
25
|
+
* conditional credential writes and immediate, user-scoped session revocation.
|
|
26
|
+
*
|
|
23
27
|
* The service runners take a factory plus seed helpers because `add` /
|
|
24
28
|
* `register` methods aren't part of the public service interfaces — the
|
|
25
29
|
* consumer tells the runner how to populate test data.
|
|
@@ -39,4 +43,6 @@ export { type OtpStoreContractOptions, runOtpStoreContractTests, } from "./otp-s
|
|
|
39
43
|
export { type RateLimitStoreContractOptions, runRateLimitStoreContractTests, } from "./rate-limit-store.js";
|
|
40
44
|
export { type LockoutStoreContractOptions, runLockoutStoreContractTests, } from "./lockout-store.js";
|
|
41
45
|
export { type AuthRequestStorageContractOptions, runAuthRequestStorageContractTests, } from "./auth-request-storage.js";
|
|
42
|
-
export { runTenantContractTests, type TenantContractFixture, type TenantContractOptions, type TenantContractProfile, type TenantContractSignInOptions, type TenantContractTokens, } from "./tenant.js";
|
|
46
|
+
export { runTenantContractTests, type TenantContractClient, type TenantContractFixture, type TenantContractMachineClientOptions, type TenantContractMachinePermission, type TenantContractOptions, type TenantContractProfile, type TenantContractSignInOptions, type TenantContractTokens, } from "./tenant.js";
|
|
47
|
+
export { type IdentityUserContractProfile, type IdentityUserStoreContractFixture, type IdentityUserStoreContractOptions, runIdentityUserStoreContractTests, } from "./identity-user-store.js";
|
|
48
|
+
export { type IdentitySessionContractState, type ListableSessionServiceContractFixture, type ListableSessionServiceContractOptions, type RevocableSessionServiceContractFixture, type RevocableSessionServiceContractOptions, runListableSessionServiceContractTests, runRevocableSessionServiceContractTests, } from "./identity-session.js";
|
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
* sequential case and fails the concurrent one, which is the whole
|
|
21
21
|
* reason the suites exist.
|
|
22
22
|
*
|
|
23
|
+
* Identity user storage, session revocation, and authoritative session listing
|
|
24
|
+
* also have separate suites. They use app-owned fixture helpers to verify
|
|
25
|
+
* conditional credential writes and immediate, user-scoped session revocation.
|
|
26
|
+
*
|
|
23
27
|
* The service runners take a factory plus seed helpers because `add` /
|
|
24
28
|
* `register` methods aren't part of the public service interfaces — the
|
|
25
29
|
* consumer tells the runner how to populate test data.
|
|
@@ -40,3 +44,5 @@ export { runRateLimitStoreContractTests, } from "./rate-limit-store.js";
|
|
|
40
44
|
export { runLockoutStoreContractTests, } from "./lockout-store.js";
|
|
41
45
|
export { runAuthRequestStorageContractTests, } from "./auth-request-storage.js";
|
|
42
46
|
export { runTenantContractTests, } from "./tenant.js";
|
|
47
|
+
export { runIdentityUserStoreContractTests, } from "./identity-user-store.js";
|
|
48
|
+
export { runListableSessionServiceContractTests, runRevocableSessionServiceContractTests, } from "./identity-session.js";
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
* about its signed-in people: what introspection reports, what
|
|
4
4
|
* `GET /api/memberships` lists, what `POST /api/check` and
|
|
5
5
|
* `POST /api/check/batch` answer, the organization API under
|
|
6
|
-
* `/api/organizations`, and the account API under `/api/account
|
|
6
|
+
* `/api/organizations`, and the account API under `/api/account` — and what it
|
|
7
|
+
* answers the application itself: the `client_credentials` grant, and
|
|
8
|
+
* `GET /api/resource-grants` under the application's own machine token.
|
|
7
9
|
*
|
|
8
10
|
* It runs against `createFakeTenant` from `@udibo/oauth2/testing` and against the
|
|
9
11
|
* real identity service in Udibo's own repository, so the fake an app tests
|
|
@@ -94,9 +96,50 @@ export interface TenantContractFixture {
|
|
|
94
96
|
* own, unless `options.sameBrowser` says otherwise.
|
|
95
97
|
*/
|
|
96
98
|
signIn(userId: string, organizationId?: string, options?: TenantContractSignInOptions): Promise<TenantContractTokens>;
|
|
99
|
+
/**
|
|
100
|
+
* Registers an application allowed the `client_credentials` grant and no
|
|
101
|
+
* other, whose tokens may carry `options.scopes` and nothing else, and which
|
|
102
|
+
* a tenant administrator has assigned `options.permissions`. It is
|
|
103
|
+
* confidential unless `options.confidential` is `false`. Returns the
|
|
104
|
+
* credentials it signs in with: its id, and its secret when it has one.
|
|
105
|
+
*/
|
|
106
|
+
addMachineClient(options: TenantContractMachineClientOptions): Promise<TenantContractClient>;
|
|
97
107
|
/** Releases anything `setup` allocated. */
|
|
98
108
|
cleanup?(): Promise<void> | void;
|
|
99
109
|
}
|
|
110
|
+
/** An application's credentials at the token endpoint. */
|
|
111
|
+
export interface TenantContractClient {
|
|
112
|
+
/** The client id. */
|
|
113
|
+
id: string;
|
|
114
|
+
/** The client secret, which a public application is never issued. */
|
|
115
|
+
secret?: string;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* A management permission a tenant administrator assigns a machine
|
|
119
|
+
* application. The suite assigns `resource_grants.read`, which is what
|
|
120
|
+
* `GET /api/resource-grants` requires.
|
|
121
|
+
*/
|
|
122
|
+
export type TenantContractMachinePermission = "resource_grants.read";
|
|
123
|
+
/** What `TenantContractFixture.addMachineClient` registers. */
|
|
124
|
+
export interface TenantContractMachineClientOptions {
|
|
125
|
+
/**
|
|
126
|
+
* The scopes the application's tokens may carry. The suite names
|
|
127
|
+
* `identity:organizations:read` alone, and expects a token asking for any
|
|
128
|
+
* other to be refused.
|
|
129
|
+
*/
|
|
130
|
+
scopes: string[];
|
|
131
|
+
/** The permissions an administrator assigned the application. */
|
|
132
|
+
permissions: TenantContractMachinePermission[];
|
|
133
|
+
/**
|
|
134
|
+
* Whether the application is confidential, authenticating with a secret.
|
|
135
|
+
* Defaults to `true`. With `false` it is public and issued no secret, and
|
|
136
|
+
* the suite expects the token endpoint to refuse it the grant as
|
|
137
|
+
* `invalid_client`. A tenant that refuses to register a public application
|
|
138
|
+
* allowed the grant may leave the grant off, since that refusal is client
|
|
139
|
+
* authentication's and comes before the grant is considered.
|
|
140
|
+
*/
|
|
141
|
+
confidential?: boolean;
|
|
142
|
+
}
|
|
100
143
|
/** How `TenantContractFixture.signIn` signs a person in. */
|
|
101
144
|
export interface TenantContractSignInOptions {
|
|
102
145
|
/**
|