@udibo/oauth2 0.10.0 → 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.
@@ -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.
@@ -40,3 +44,5 @@ export { type RateLimitStoreContractOptions, runRateLimitStoreContractTests, } f
40
44
  export { type LockoutStoreContractOptions, runLockoutStoreContractTests, } from "./lockout-store.js";
41
45
  export { type AuthRequestStorageContractOptions, runAuthRequestStorageContractTests, } from "./auth-request-storage.js";
42
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";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@udibo/oauth2",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "A flexible OAuth2 toolkit: framework-agnostic authorization & resource servers, a typed client, Hono adapters (including a backend-for-frontend), and React bindings.",
5
5
  "repository": {
6
6
  "type": "git",