@owlmeans/server-auth-session 0.1.18-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,45 @@
1
+ # @owlmeans/server-auth-session
2
+
3
+ A bounded seven-day authority registry for OwlMeans bearer and OIDC sessions. It supplies an
4
+ in-memory implementation for local/single-process applications and an atomic Redis implementation
5
+ for multi-instance production deployments.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ bun add @owlmeans/server-auth-session@^0.1.18-rc.1
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ Register the memory manager before `appendAuthService` when a local application needs immediate
16
+ revocation or claim-refresh decisions:
17
+
18
+ ```typescript
19
+ import { appendMemoryAuthSessionManager } from '@owlmeans/server-auth-session'
20
+ import { appendAuthService } from '@owlmeans/server-auth'
21
+
22
+ appendMemoryAuthSessionManager(context)
23
+ appendAuthService(context)
24
+ ```
25
+
26
+ Use `appendRedisAuthSessionManager` in a multi-instance deployment. The application must register
27
+ its Redis resource first. Call `fence()` before changing a profile's authority, then `refresh()`
28
+ to require fresh claims or `revoke()` to reject all affected sessions. Session expiry is absolute;
29
+ neither manager extends it beyond seven days.
30
+
31
+ <!-- owlmeans:agent-guidance:start -->
32
+ ## Agent guidance
33
+
34
+ This package ships embedded agent skills under `agent-meta/`. After installing your
35
+ `@owlmeans/*` packages, run the OwlMeans agent-skills installer to place them into
36
+ your project's skill store (`.agents/skills/`):
37
+
38
+ ```sh
39
+ npx @owlmeans/agent-skills@^0.1.18-rc.30
40
+ ```
41
+
42
+ The embedded files are version-matched to this package release. Do not edit them
43
+ directly — they are regenerated on each publish. To contribute guidance edits,
44
+ open a PR against the source monorepo.
45
+ <!-- owlmeans:agent-guidance:end -->
@@ -0,0 +1,16 @@
1
+ {
2
+ "schemaVersion": 2,
3
+ "package": "@owlmeans/server-auth-session",
4
+ "version": "0.1.18-rc.1",
5
+ "generatedAt": "2026-09-18T19:19:03.112Z",
6
+ "canonicalRepo": "https://github.com/owlmeans/common",
7
+ "entries": [
8
+ {
9
+ "kind": "skill",
10
+ "name": "server-auth-session",
11
+ "category": "package-specific",
12
+ "file": "skills/server-auth-session/SKILL.md",
13
+ "canonicalPath": ".agents/skills/server-auth-session/SKILL.md"
14
+ }
15
+ ]
16
+ }
@@ -0,0 +1,42 @@
1
+ ---
2
+ name: server-auth-session
3
+ description: How to use @owlmeans/server-auth-session for absolute seven-day bearer/OIDC session tracking, Redis authority, local memory defaults, and profile-level fence/refresh/revoke decisions. Use when issuing, validating, revoking, or refreshing OwlMeans sessions.
4
+ user-invocable: false
5
+ ---
6
+ <!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
7
+
8
+ # @owlmeans/server-auth-session
9
+
10
+ **Layer:** Server
11
+
12
+ The session manager is an authorization-freshness registry, not a browser-token store. A session
13
+ record is addressed by an opaque id; its subject is the stable `{ entityId, profileId, clientId? }`
14
+ selector. `entityId` is storage-only and must never be placed in an authentication envelope.
15
+
16
+ ## Exports
17
+
18
+ | Export | Purpose |
19
+ |---|---|
20
+ | `AUTH_SESSION_TTL` | Absolute seven-day maximum; acknowledgement never extends it. |
21
+ | `AUTH_SESSION_MANAGER` | Context service alias. |
22
+ | `AUTH_SESSION_RESOURCE` | Redis resource alias. |
23
+ | `appendMemoryAuthSessionManager(context)` | Process-local default for tests, development and generated targets. |
24
+ | `appendRedisAuthSessionManager(context)` | Shared Redis authority for a central/multi-replica issuer. |
25
+ | `AuthSessionManager` | `register`, `inspect`, `fence`, `refresh`, `revoke` contract. |
26
+
27
+ ## Rules
28
+
29
+ - Register the manager before `appendAuthService`. The auth service then issues an opaque session
30
+ id, uses the registry at every bearer decision, and rejects pre-registry tokens after rollout.
31
+ - `register` clamps every caller-supplied expiry to `AUTH_SESSION_TTL`. Re-registering after a
32
+ verified claim refresh updates the subject version but preserves the original absolute expiry.
33
+ - Before changing a profile's permissions, role, disabled flag or existence, call `fence(selector,
34
+ operationId)`. On a successful durable write call `refresh` for changed claims or `revoke` for
35
+ disablement/removal. If the write or completion fails, leave the subject pending and refuse it.
36
+ - Use `appendRedisAuthSessionManager` for viable or any central/scaled issuer. Its Lua transition
37
+ owns the operation id and version atomically; do not replace it with a read-modify-save sequence.
38
+ - The memory implementation intentionally forgets state on restart. It is suitable only when a
39
+ separate authority makes a live decision on each request, as viable does for generated targets'
40
+ required OIDC validation.
41
+ - A registry read/write failure is an `AuthUnavailable` path (503). Never interpret it as a missing
42
+ session, a logout, or permission to use stale claims.
@@ -0,0 +1,5 @@
1
+ /** Seven days is an absolute session lifetime; refreshes never extend it. */
2
+ export declare const AUTH_SESSION_TTL: number;
3
+ export declare const AUTH_SESSION_MANAGER = "auth-session-manager";
4
+ export declare const AUTH_SESSION_RESOURCE = "auth-session-record";
5
+ //# sourceMappingURL=consts.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"consts.d.ts","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AAAA,6EAA6E;AAC7E,eAAO,MAAM,gBAAgB,QAA0B,CAAA;AAEvD,eAAO,MAAM,oBAAoB,yBAAyB,CAAA;AAE1D,eAAO,MAAM,qBAAqB,wBAAwB,CAAA"}
@@ -0,0 +1,5 @@
1
+ /** Seven days is an absolute session lifetime; refreshes never extend it. */
2
+ export const AUTH_SESSION_TTL = 7 * 24 * 60 * 60 * 1000;
3
+ export const AUTH_SESSION_MANAGER = 'auth-session-manager';
4
+ export const AUTH_SESSION_RESOURCE = 'auth-session-record';
5
+ //# sourceMappingURL=consts.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"consts.js","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AAAA,6EAA6E;AAC7E,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAA;AAEvD,MAAM,CAAC,MAAM,oBAAoB,GAAG,sBAAsB,CAAA;AAE1D,MAAM,CAAC,MAAM,qBAAqB,GAAG,qBAAqB,CAAA"}
@@ -0,0 +1,4 @@
1
+ import type { AuthSessionSelector } from './types.js';
2
+ /** Never derive storage identity from the renameable organization slug. */
3
+ export declare const subjectId: ({ entityId, profileId, clientId }: AuthSessionSelector) => string;
4
+ //# sourceMappingURL=identity.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"identity.d.ts","sourceRoot":"","sources":["../src/identity.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAA;AAErD,2EAA2E;AAC3E,eAAO,MAAM,SAAS,sCAAuC,mBAAmB,KAAG,MAC7B,CAAA"}
@@ -0,0 +1,3 @@
1
+ /** Never derive storage identity from the renameable organization slug. */
2
+ export const subjectId = ({ entityId, profileId, clientId }) => `subject:${entityId}:${profileId}:${clientId ?? ''}`;
3
+ //# sourceMappingURL=identity.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"identity.js","sourceRoot":"","sources":["../src/identity.ts"],"names":[],"mappings":"AAEA,2EAA2E;AAC3E,MAAM,CAAC,MAAM,SAAS,GAAG,CAAC,EAAE,QAAQ,EAAE,SAAS,EAAE,QAAQ,EAAuB,EAAU,EAAE,CAC1F,WAAW,QAAQ,IAAI,SAAS,IAAI,QAAQ,IAAI,EAAE,EAAE,CAAA"}
@@ -0,0 +1,5 @@
1
+ export * from './consts.js';
2
+ export * from './memory.js';
3
+ export * from './redis.js';
4
+ export type * from './types.js';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,YAAY,CAAA;AAC1B,mBAAmB,YAAY,CAAA"}
package/build/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export * from './consts.js';
2
+ export * from './memory.js';
3
+ export * from './redis.js';
4
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,YAAY,CAAA"}
@@ -0,0 +1,9 @@
1
+ import type { BasicContext } from '@owlmeans/context';
2
+ import type { AuthSessionManager, AuthSessionManagerOptions } from './types.js';
3
+ /**
4
+ * Process-local implementation for development and generated targets. It deliberately loses
5
+ * state on restart and is therefore never the authority for viable's centrally issued sessions.
6
+ */
7
+ export declare const makeMemoryAuthSessionManager: (options?: AuthSessionManagerOptions) => AuthSessionManager;
8
+ export declare const appendMemoryAuthSessionManager: <C extends BasicContext<any>>(context: C, options?: AuthSessionManagerOptions) => C;
9
+ //# sourceMappingURL=memory.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"memory.d.ts","sourceRoot":"","sources":["../src/memory.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAGrD,OAAO,KAAK,EACW,kBAAkB,EAAE,yBAAyB,EAEnE,MAAM,YAAY,CAAA;AAInB;;;GAGG;AACH,eAAO,MAAM,4BAA4B,aAC9B,yBAAyB,KACjC,kBAiFF,CAAA;AAED,eAAO,MAAM,8BAA8B,GAAI,CAAC,SAAS,YAAY,CAAC,GAAG,CAAC,WAC/D,CAAC,YAAY,yBAAyB,KAC9C,CAIF,CAAA"}
@@ -0,0 +1,103 @@
1
+ import { createService } from '@owlmeans/context';
2
+ import { AUTH_SESSION_MANAGER, AUTH_SESSION_TTL } from './consts.js';
3
+ import { subjectId } from './identity.js';
4
+ const missing = { state: 'missing' };
5
+ /**
6
+ * Process-local implementation for development and generated targets. It deliberately loses
7
+ * state on restart and is therefore never the authority for viable's centrally issued sessions.
8
+ */
9
+ export const makeMemoryAuthSessionManager = (options = {}) => {
10
+ const now = options.now ?? Date.now;
11
+ const sessions = new Map();
12
+ const subjects = new Map();
13
+ const activeSubject = (selector, at) => {
14
+ const id = subjectId(selector);
15
+ const current = subjects.get(id);
16
+ if (current != null && current.expiresAt > at)
17
+ return current;
18
+ const created = {
19
+ id, record: 'subject', entityId: selector.entityId, profileId: selector.profileId,
20
+ ...(selector.clientId != null ? { clientId: selector.clientId } : {}),
21
+ state: 'active', version: 1, expiresAt: at + AUTH_SESSION_TTL
22
+ };
23
+ subjects.set(id, created);
24
+ return created;
25
+ };
26
+ const decision = (session) => {
27
+ const at = now();
28
+ if (session == null)
29
+ return missing;
30
+ if (session.expiresAt <= at) {
31
+ sessions.delete(session.id);
32
+ return { state: 'expired' };
33
+ }
34
+ const subject = subjects.get(subjectId(session));
35
+ if (subject == null || subject.expiresAt <= at)
36
+ return missing;
37
+ if (subject.state === 'pending')
38
+ return { state: 'pending' };
39
+ if (subject.state === 'revoked')
40
+ return { state: 'revoked' };
41
+ return {
42
+ state: subject.version > session.version ? 'refresh' : 'active',
43
+ version: subject.version,
44
+ expiresAt: session.expiresAt
45
+ };
46
+ };
47
+ const service = createService(options.alias ?? AUTH_SESSION_MANAGER, {
48
+ register: async (session) => {
49
+ const at = now();
50
+ // Compute the individual token's absolute end before looking up the profile subject. A
51
+ // subject is shared by successive logins, so it must survive at least as long as its newest
52
+ // still-valid session; otherwise a login near the first session's seventh day would be
53
+ // rejected early when the original subject record expired.
54
+ const expiresAt = Math.min(session.expiresAt ?? at + AUTH_SESSION_TTL, at + AUTH_SESSION_TTL);
55
+ if (expiresAt <= at)
56
+ return { state: 'expired' };
57
+ const subject = activeSubject(session, at);
58
+ if (subject.state === 'pending')
59
+ return { state: 'pending' };
60
+ if (subject.state === 'revoked')
61
+ return { state: 'revoked' };
62
+ if (subject.expiresAt < expiresAt) {
63
+ subjects.set(subject.id, { ...subject, expiresAt });
64
+ }
65
+ // Callers may request a shorter session, never a longer one. The registry is the last
66
+ // line of defence for the absolute seven-day cap, not a convention callers can bypass.
67
+ sessions.set(session.id, { ...session, issuedAt: session.issuedAt ?? at, expiresAt, version: subject.version });
68
+ return { state: 'active', version: subject.version, expiresAt };
69
+ },
70
+ inspect: async (id) => decision(sessions.get(id)),
71
+ fence: async (selector, operationId) => {
72
+ const at = now();
73
+ const subject = activeSubject(selector, at);
74
+ subjects.set(subject.id, { ...subject, state: 'pending', operationId, expiresAt: Math.max(subject.expiresAt, at + AUTH_SESSION_TTL) });
75
+ },
76
+ refresh: async (selector, operationId) => {
77
+ const at = now();
78
+ const subject = activeSubject(selector, at);
79
+ if (subject.state === 'pending' && subject.operationId !== operationId)
80
+ return subject.version;
81
+ const version = subject.version + 1;
82
+ subjects.set(subject.id, { ...subject, state: 'active', version, operationId: undefined, expiresAt: Math.max(subject.expiresAt, at + AUTH_SESSION_TTL) });
83
+ return version;
84
+ },
85
+ revoke: async (selector, operationId) => {
86
+ const at = now();
87
+ const subject = activeSubject(selector, at);
88
+ if (subject.state === 'pending' && subject.operationId !== operationId)
89
+ return subject.version;
90
+ const version = subject.version + 1;
91
+ subjects.set(subject.id, { ...subject, state: 'revoked', version, operationId: undefined, expiresAt: Math.max(subject.expiresAt, at + AUTH_SESSION_TTL) });
92
+ return version;
93
+ }
94
+ });
95
+ return service;
96
+ };
97
+ export const appendMemoryAuthSessionManager = (context, options) => {
98
+ const alias = options?.alias ?? AUTH_SESSION_MANAGER;
99
+ if (!context.hasService(alias))
100
+ context.registerService(makeMemoryAuthSessionManager(options));
101
+ return context;
102
+ };
103
+ //# sourceMappingURL=memory.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"memory.js","sourceRoot":"","sources":["../src/memory.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,12 @@
1
+ import type { BasicContext } from '@owlmeans/context';
2
+ import type { RedisResource } from '@owlmeans/redis-resource';
3
+ import type { AuthSessionManager, AuthSessionManagerOptions } from './types.js';
4
+ /** Redis-backed manager. Redis `create` is SET NX, so first session registration is atomic. */
5
+ export declare const makeRedisAuthSessionManager: (resourceAlias?: string, options?: AuthSessionManagerOptions) => AuthSessionManager;
6
+ export declare const appendRedisAuthSessionManager: <C extends BasicContext<any>>(context: C, options?: AuthSessionManagerOptions & {
7
+ resourceAlias?: string;
8
+ dbAlias?: string;
9
+ serviceAlias?: string;
10
+ }) => C;
11
+ export type { RedisResource };
12
+ //# sourceMappingURL=redis.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redis.d.ts","sourceRoot":"","sources":["../src/redis.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAErD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAA;AAI7D,OAAO,KAAK,EACW,kBAAkB,EAAE,yBAAyB,EAEnE,MAAM,YAAY,CAAA;AAkDnB,+FAA+F;AAC/F,eAAO,MAAM,2BAA2B,mBACvB,MAAM,YAAmC,yBAAyB,KAChF,kBA4GF,CAAA;AAED,eAAO,MAAM,6BAA6B,GAAI,CAAC,SAAS,YAAY,CAAC,GAAG,CAAC,WAC9D,CAAC,YAAY,yBAAyB,GAAG;IAAE,aAAa,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE,KACpH,CAQF,CAAA;AAED,YAAY,EAAE,aAAa,EAAE,CAAA"}
package/build/redis.js ADDED
@@ -0,0 +1,173 @@
1
+ import { createService } from '@owlmeans/context';
2
+ import { makeRedisResource } from '@owlmeans/redis-resource';
3
+ import { RecordExists } from '@owlmeans/resource';
4
+ import { AUTH_SESSION_MANAGER, AUTH_SESSION_RESOURCE, AUTH_SESSION_TTL } from './consts.js';
5
+ import { subjectId } from './identity.js';
6
+ const missing = { state: 'missing' };
7
+ /**
8
+ * One Redis script owns each subject transition. A read-modify-save sequence lets competing
9
+ * grant/revoke operations overwrite a pending fence; this compares the fence id and increments
10
+ * the version in the same Redis command instead.
11
+ */
12
+ const transitionSubject = `
13
+ local current = redis.call('GET', KEYS[1])
14
+ local subject = current and cjson.decode(current) or cjson.decode(ARGV[1])
15
+ local now = tonumber(ARGV[2])
16
+ local expires = tonumber(ARGV[3])
17
+ local state = ARGV[4]
18
+ local operation = ARGV[5]
19
+ if tonumber(subject.expiresAt) <= now then
20
+ subject = cjson.decode(ARGV[1])
21
+ end
22
+ if state == 'pending' then
23
+ subject.state = 'pending'
24
+ subject.operationId = operation
25
+ else
26
+ if subject.state == 'pending' and subject.operationId ~= operation then
27
+ return -1
28
+ end
29
+ subject.state = state
30
+ subject.operationId = cjson.null
31
+ subject.version = tonumber(subject.version) + 1
32
+ end
33
+ subject.expiresAt = math.max(tonumber(subject.expiresAt), expires)
34
+ redis.call('SET', KEYS[1], cjson.encode(subject), 'PXAT', subject.expiresAt)
35
+ return tonumber(subject.version)
36
+ `;
37
+ /** Extend only the subject's expiry without overwriting a concurrent fence or revocation. */
38
+ const extendSubjectExpiry = `
39
+ local current = redis.call('GET', KEYS[1])
40
+ if not current then return 0 end
41
+ local subject = cjson.decode(current)
42
+ local expires = tonumber(ARGV[1])
43
+ if tonumber(subject.expiresAt) < expires then
44
+ subject.expiresAt = expires
45
+ redis.call('SET', KEYS[1], cjson.encode(subject), 'PXAT', subject.expiresAt)
46
+ end
47
+ return 1
48
+ `;
49
+ /** Redis-backed manager. Redis `create` is SET NX, so first session registration is atomic. */
50
+ export const makeRedisAuthSessionManager = (resourceAlias = AUTH_SESSION_RESOURCE, options = {}) => {
51
+ const now = options.now ?? Date.now;
52
+ const resource = (context) => context.resource(resourceAlias);
53
+ const loadSubject = async (ctx, selector, at) => {
54
+ const id = subjectId(selector);
55
+ const found = await resource(ctx).load(id);
56
+ if (found != null && found.record === 'subject' && found.expiresAt > at)
57
+ return found;
58
+ const created = {
59
+ id, record: 'subject', entityId: selector.entityId, profileId: selector.profileId,
60
+ ...(selector.clientId != null ? { clientId: selector.clientId } : {}),
61
+ state: 'active', version: 1, expiresAt: at + AUTH_SESSION_TTL
62
+ };
63
+ try {
64
+ await resource(ctx).create(created, { ttl: new Date(created.expiresAt) });
65
+ return created;
66
+ }
67
+ catch (error) {
68
+ if (!(error instanceof RecordExists))
69
+ throw error;
70
+ const raced = await resource(ctx).load(id);
71
+ if (raced == null || raced.record !== 'subject')
72
+ throw error;
73
+ return raced;
74
+ }
75
+ };
76
+ const transition = async (ctx, selector, state, operationId) => {
77
+ const at = now();
78
+ const id = subjectId(selector);
79
+ const seed = {
80
+ id, record: 'subject', entityId: selector.entityId, profileId: selector.profileId,
81
+ ...(selector.clientId != null ? { clientId: selector.clientId } : {}),
82
+ state: 'active', version: 1, expiresAt: at + AUTH_SESSION_TTL
83
+ };
84
+ const expiresAt = at + AUTH_SESSION_TTL;
85
+ const value = await resource(ctx).db.client.eval(transitionSubject, 1, resource(ctx).key(id), JSON.stringify(seed), at, expiresAt, state, operationId);
86
+ const version = Number(value);
87
+ if (!Number.isInteger(version) || version < 0) {
88
+ throw new Error('auth-session:transition-conflict');
89
+ }
90
+ return version;
91
+ };
92
+ let service;
93
+ service = createService(options.alias ?? AUTH_SESSION_MANAGER, {
94
+ register: async (session) => {
95
+ const ctx = service.assertCtx();
96
+ const at = now();
97
+ // Do not trust a caller to choose the maximum session TTL. It may choose a shorter expiry.
98
+ const expiresAt = Math.min(session.expiresAt ?? at + AUTH_SESSION_TTL, at + AUTH_SESSION_TTL);
99
+ if (expiresAt <= at)
100
+ return { state: 'expired' };
101
+ const subject = await loadSubject(ctx, session, at);
102
+ if (subject.state === 'pending')
103
+ return { state: 'pending' };
104
+ if (subject.state === 'revoked')
105
+ return { state: 'revoked' };
106
+ // Session records have their own absolute expiry. Keep the shared subject alive until the
107
+ // newest one expires, but use a script so an expiry extension cannot replace a concurrent
108
+ // pending/revoked state with this older active object.
109
+ if (subject.expiresAt < expiresAt) {
110
+ await resource(ctx).db.client.eval(extendSubjectExpiry, 1, resource(ctx).key(subject.id), expiresAt);
111
+ }
112
+ const stored = {
113
+ ...session, record: 'session', issuedAt: session.issuedAt ?? at, expiresAt,
114
+ version: subject.version
115
+ };
116
+ try {
117
+ await resource(ctx).create(stored, { ttl: new Date(expiresAt) });
118
+ }
119
+ catch (error) {
120
+ if (!(error instanceof RecordExists))
121
+ throw error;
122
+ // A subject version may have advanced since its original session record was minted.
123
+ // A successful revalidation acknowledges that version without extending the absolute TTL.
124
+ const current = await resource(ctx).load(session.id);
125
+ if (current == null || current.record !== 'session')
126
+ throw error;
127
+ await resource(ctx).save({ ...stored, expiresAt: current.expiresAt }, { ttl: new Date(current.expiresAt) });
128
+ }
129
+ return { state: 'active', version: subject.version, expiresAt };
130
+ },
131
+ inspect: async (id) => {
132
+ const ctx = service.assertCtx();
133
+ const at = now();
134
+ const session = await resource(ctx).load(id);
135
+ if (session == null || session.record !== 'session')
136
+ return missing;
137
+ if (session.expiresAt <= at)
138
+ return { state: 'expired' };
139
+ const subject = await resource(ctx).load(subjectId(session));
140
+ if (subject == null || subject.record !== 'subject' || subject.expiresAt <= at)
141
+ return missing;
142
+ if (subject.state === 'pending')
143
+ return { state: 'pending' };
144
+ if (subject.state === 'revoked')
145
+ return { state: 'revoked' };
146
+ return { state: subject.version > session.version ? 'refresh' : 'active', version: subject.version, expiresAt: session.expiresAt };
147
+ },
148
+ fence: async (selector, operationId) => {
149
+ const ctx = service.assertCtx();
150
+ await transition(ctx, selector, 'pending', operationId);
151
+ },
152
+ refresh: async (selector, operationId) => {
153
+ const ctx = service.assertCtx();
154
+ return await transition(ctx, selector, 'active', operationId);
155
+ },
156
+ revoke: async (selector, operationId) => {
157
+ const ctx = service.assertCtx();
158
+ return await transition(ctx, selector, 'revoked', operationId);
159
+ }
160
+ });
161
+ return service;
162
+ };
163
+ export const appendRedisAuthSessionManager = (context, options) => {
164
+ const resourceAlias = options?.resourceAlias ?? AUTH_SESSION_RESOURCE;
165
+ if (!context.hasResource(resourceAlias)) {
166
+ context.registerResource(makeRedisResource(resourceAlias, options?.dbAlias, options?.serviceAlias));
167
+ }
168
+ const alias = options?.alias ?? AUTH_SESSION_MANAGER;
169
+ if (!context.hasService(alias))
170
+ context.registerService(makeRedisAuthSessionManager(resourceAlias, options));
171
+ return context;
172
+ };
173
+ //# sourceMappingURL=redis.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redis.js","sourceRoot":"","sources":["../src/redis.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,55 @@
1
+ import type { InitializedService } from '@owlmeans/context';
2
+ import type { ResourceRecord } from '@owlmeans/resource';
3
+ export type AuthSessionKind = 'bearer' | 'oidc-access' | 'oidc-refresh' | 'oidc-provider-session';
4
+ export type AuthSessionState = 'active' | 'pending' | 'refresh' | 'revoked';
5
+ /** Stable storage selector. `entityId` never travels in an authentication envelope. */
6
+ export interface AuthSessionSelector {
7
+ entityId: string;
8
+ profileId: string;
9
+ clientId?: string;
10
+ }
11
+ export interface AuthSessionRecord extends ResourceRecord, AuthSessionSelector {
12
+ id: string;
13
+ record: 'session';
14
+ kind: AuthSessionKind;
15
+ version: number;
16
+ issuedAt: number;
17
+ expiresAt: number;
18
+ }
19
+ export interface AuthSessionSubject extends ResourceRecord, AuthSessionSelector {
20
+ id: string;
21
+ record: 'subject';
22
+ state: AuthSessionState;
23
+ version: number;
24
+ expiresAt: number;
25
+ operationId?: string;
26
+ }
27
+ export type AuthSessionStoredRecord = AuthSessionRecord | AuthSessionSubject;
28
+ export type AuthSessionDecision = {
29
+ state: 'active' | 'refresh';
30
+ version: number;
31
+ expiresAt: number;
32
+ } | {
33
+ state: 'pending' | 'revoked' | 'missing' | 'expired';
34
+ };
35
+ export interface RegisterAuthSession extends AuthSessionSelector {
36
+ id: string;
37
+ kind: AuthSessionKind;
38
+ issuedAt?: number;
39
+ expiresAt?: number;
40
+ }
41
+ export interface AuthSessionManager extends InitializedService {
42
+ register: (session: RegisterAuthSession) => Promise<AuthSessionDecision>;
43
+ inspect: (id: string) => Promise<AuthSessionDecision>;
44
+ /** Fences all sessions for a profile/client before its authority is changed. */
45
+ fence: (selector: AuthSessionSelector, operationId: string) => Promise<void>;
46
+ /** Completes a fence as a claim refresh. */
47
+ refresh: (selector: AuthSessionSelector, operationId: string) => Promise<number>;
48
+ /** Completes a fence as a session revocation. */
49
+ revoke: (selector: AuthSessionSelector, operationId: string) => Promise<number>;
50
+ }
51
+ export interface AuthSessionManagerOptions {
52
+ alias?: string;
53
+ now?: () => number;
54
+ }
55
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAA;AAC3D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAA;AAExD,MAAM,MAAM,eAAe,GAAG,QAAQ,GAAG,aAAa,GAAG,cAAc,GAAG,uBAAuB,CAAA;AAEjG,MAAM,MAAM,gBAAgB,GAAG,QAAQ,GAAG,SAAS,GAAG,SAAS,GAAG,SAAS,CAAA;AAE3E,uFAAuF;AACvF,MAAM,WAAW,mBAAmB;IAClC,QAAQ,EAAE,MAAM,CAAA;IAChB,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAED,MAAM,WAAW,iBAAkB,SAAQ,cAAc,EAAE,mBAAmB;IAC5E,EAAE,EAAE,MAAM,CAAA;IACV,MAAM,EAAE,SAAS,CAAA;IACjB,IAAI,EAAE,eAAe,CAAA;IACrB,OAAO,EAAE,MAAM,CAAA;IACf,QAAQ,EAAE,MAAM,CAAA;IAChB,SAAS,EAAE,MAAM,CAAA;CAClB;AAED,MAAM,WAAW,kBAAmB,SAAQ,cAAc,EAAE,mBAAmB;IAC7E,EAAE,EAAE,MAAM,CAAA;IACV,MAAM,EAAE,SAAS,CAAA;IACjB,KAAK,EAAE,gBAAgB,CAAA;IACvB,OAAO,EAAE,MAAM,CAAA;IACf,SAAS,EAAE,MAAM,CAAA;IACjB,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,MAAM,MAAM,uBAAuB,GAAG,iBAAiB,GAAG,kBAAkB,CAAA;AAE5E,MAAM,MAAM,mBAAmB,GAC3B;IAAE,KAAK,EAAE,QAAQ,GAAG,SAAS,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,GACnE;IAAE,KAAK,EAAE,SAAS,GAAG,SAAS,GAAG,SAAS,GAAG,SAAS,CAAA;CAAE,CAAA;AAE5D,MAAM,WAAW,mBAAoB,SAAQ,mBAAmB;IAC9D,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,eAAe,CAAA;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,MAAM,WAAW,kBAAmB,SAAQ,kBAAkB;IAC5D,QAAQ,EAAE,CAAC,OAAO,EAAE,mBAAmB,KAAK,OAAO,CAAC,mBAAmB,CAAC,CAAA;IACxE,OAAO,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,mBAAmB,CAAC,CAAA;IACrD,gFAAgF;IAChF,KAAK,EAAE,CAAC,QAAQ,EAAE,mBAAmB,EAAE,WAAW,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;IAC5E,4CAA4C;IAC5C,OAAO,EAAE,CAAC,QAAQ,EAAE,mBAAmB,EAAE,WAAW,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAA;IAChF,iDAAiD;IACjD,MAAM,EAAE,CAAC,QAAQ,EAAE,mBAAmB,EAAE,WAAW,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAA;CAChF;AAED,MAAM,WAAW,yBAAyB;IACxC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CACnB"}
package/build/types.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@owlmeans/server-auth-session",
3
+ "version": "0.1.18-rc.1",
4
+ "license": "MIT",
5
+ "description": "Short-lived OwlMeans session registry with memory and Redis implementations.",
6
+ "type": "module",
7
+ "scripts": {
8
+ "build": "tsc -b",
9
+ "test": "bun test ./tests"
10
+ },
11
+ "main": "build/index.js",
12
+ "module": "build/index.js",
13
+ "types": "build/index.d.ts",
14
+ "exports": {
15
+ ".": {
16
+ "import": "./build/index.js",
17
+ "require": "./build/index.js",
18
+ "default": "./build/index.js",
19
+ "module": "./build/index.js",
20
+ "types": "./build/index.d.ts"
21
+ }
22
+ },
23
+ "dependencies": {
24
+ "@owlmeans/context": "^0.1.18-rc.26",
25
+ "@owlmeans/redis-resource": "^0.1.18-rc.29",
26
+ "@owlmeans/resource": "^0.1.18-rc.28",
27
+ "@owlmeans/server-context": "^0.1.18-rc.29"
28
+ },
29
+ "devDependencies": {
30
+ "@owlmeans/dep-config": "workspace:*",
31
+ "@types/bun": "^1.4.0",
32
+ "typescript": "^7.0.2"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ }
37
+ }
package/src/consts.ts ADDED
@@ -0,0 +1,6 @@
1
+ /** Seven days is an absolute session lifetime; refreshes never extend it. */
2
+ export const AUTH_SESSION_TTL = 7 * 24 * 60 * 60 * 1000
3
+
4
+ export const AUTH_SESSION_MANAGER = 'auth-session-manager'
5
+
6
+ export const AUTH_SESSION_RESOURCE = 'auth-session-record'
@@ -0,0 +1,5 @@
1
+ import type { AuthSessionSelector } from './types.js'
2
+
3
+ /** Never derive storage identity from the renameable organization slug. */
4
+ export const subjectId = ({ entityId, profileId, clientId }: AuthSessionSelector): string =>
5
+ `subject:${entityId}:${profileId}:${clientId ?? ''}`
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export * from './consts.js'
2
+ export * from './memory.js'
3
+ export * from './redis.js'
4
+ export type * from './types.js'
package/src/memory.ts ADDED
@@ -0,0 +1,107 @@
1
+ import { createService } from '@owlmeans/context'
2
+ import type { BasicContext } from '@owlmeans/context'
3
+ import { AUTH_SESSION_MANAGER, AUTH_SESSION_TTL } from './consts.js'
4
+ import { subjectId } from './identity.js'
5
+ import type {
6
+ AuthSessionDecision, AuthSessionManager, AuthSessionManagerOptions, AuthSessionSelector,
7
+ AuthSessionSubject, RegisterAuthSession
8
+ } from './types.js'
9
+
10
+ const missing: AuthSessionDecision = { state: 'missing' }
11
+
12
+ /**
13
+ * Process-local implementation for development and generated targets. It deliberately loses
14
+ * state on restart and is therefore never the authority for viable's centrally issued sessions.
15
+ */
16
+ export const makeMemoryAuthSessionManager = (
17
+ options: AuthSessionManagerOptions = {}
18
+ ): AuthSessionManager => {
19
+ const now = options.now ?? Date.now
20
+ const sessions = new Map<string, RegisterAuthSession & { version: number, expiresAt: number }>()
21
+ const subjects = new Map<string, AuthSessionSubject>()
22
+
23
+ const activeSubject = (selector: AuthSessionSelector, at: number): AuthSessionSubject => {
24
+ const id = subjectId(selector)
25
+ const current = subjects.get(id)
26
+ if (current != null && current.expiresAt > at) return current
27
+ const created: AuthSessionSubject = {
28
+ id, record: 'subject', entityId: selector.entityId, profileId: selector.profileId,
29
+ ...(selector.clientId != null ? { clientId: selector.clientId } : {}),
30
+ state: 'active', version: 1, expiresAt: at + AUTH_SESSION_TTL
31
+ }
32
+ subjects.set(id, created)
33
+ return created
34
+ }
35
+
36
+ const decision = (session: (RegisterAuthSession & { version: number, expiresAt: number }) | undefined): AuthSessionDecision => {
37
+ const at = now()
38
+ if (session == null) return missing
39
+ if (session.expiresAt <= at) {
40
+ sessions.delete(session.id)
41
+ return { state: 'expired' }
42
+ }
43
+ const subject = subjects.get(subjectId(session))
44
+ if (subject == null || subject.expiresAt <= at) return missing
45
+ if (subject.state === 'pending') return { state: 'pending' }
46
+ if (subject.state === 'revoked') return { state: 'revoked' }
47
+ return {
48
+ state: subject.version > session.version ? 'refresh' : 'active',
49
+ version: subject.version,
50
+ expiresAt: session.expiresAt
51
+ }
52
+ }
53
+
54
+ const service = createService<AuthSessionManager>(options.alias ?? AUTH_SESSION_MANAGER, {
55
+ register: async session => {
56
+ const at = now()
57
+ // Compute the individual token's absolute end before looking up the profile subject. A
58
+ // subject is shared by successive logins, so it must survive at least as long as its newest
59
+ // still-valid session; otherwise a login near the first session's seventh day would be
60
+ // rejected early when the original subject record expired.
61
+ const expiresAt = Math.min(session.expiresAt ?? at + AUTH_SESSION_TTL, at + AUTH_SESSION_TTL)
62
+ if (expiresAt <= at) return { state: 'expired' }
63
+ const subject = activeSubject(session, at)
64
+ if (subject.state === 'pending') return { state: 'pending' }
65
+ if (subject.state === 'revoked') return { state: 'revoked' }
66
+ if (subject.expiresAt < expiresAt) {
67
+ subjects.set(subject.id, { ...subject, expiresAt })
68
+ }
69
+ // Callers may request a shorter session, never a longer one. The registry is the last
70
+ // line of defence for the absolute seven-day cap, not a convention callers can bypass.
71
+ sessions.set(session.id, { ...session, issuedAt: session.issuedAt ?? at, expiresAt, version: subject.version })
72
+ return { state: 'active', version: subject.version, expiresAt }
73
+ },
74
+ inspect: async id => decision(sessions.get(id)),
75
+ fence: async (selector, operationId) => {
76
+ const at = now()
77
+ const subject = activeSubject(selector, at)
78
+ subjects.set(subject.id, { ...subject, state: 'pending', operationId, expiresAt: Math.max(subject.expiresAt, at + AUTH_SESSION_TTL) })
79
+ },
80
+ refresh: async (selector, operationId) => {
81
+ const at = now()
82
+ const subject = activeSubject(selector, at)
83
+ if (subject.state === 'pending' && subject.operationId !== operationId) return subject.version
84
+ const version = subject.version + 1
85
+ subjects.set(subject.id, { ...subject, state: 'active', version, operationId: undefined, expiresAt: Math.max(subject.expiresAt, at + AUTH_SESSION_TTL) })
86
+ return version
87
+ },
88
+ revoke: async (selector, operationId) => {
89
+ const at = now()
90
+ const subject = activeSubject(selector, at)
91
+ if (subject.state === 'pending' && subject.operationId !== operationId) return subject.version
92
+ const version = subject.version + 1
93
+ subjects.set(subject.id, { ...subject, state: 'revoked', version, operationId: undefined, expiresAt: Math.max(subject.expiresAt, at + AUTH_SESSION_TTL) })
94
+ return version
95
+ }
96
+ })
97
+
98
+ return service
99
+ }
100
+
101
+ export const appendMemoryAuthSessionManager = <C extends BasicContext<any>>(
102
+ context: C, options?: AuthSessionManagerOptions
103
+ ): C => {
104
+ const alias = options?.alias ?? AUTH_SESSION_MANAGER
105
+ if (!context.hasService(alias)) context.registerService(makeMemoryAuthSessionManager(options))
106
+ return context
107
+ }
package/src/redis.ts ADDED
@@ -0,0 +1,186 @@
1
+ import { createService } from '@owlmeans/context'
2
+ import type { BasicContext } from '@owlmeans/context'
3
+ import { makeRedisResource } from '@owlmeans/redis-resource'
4
+ import type { RedisResource } from '@owlmeans/redis-resource'
5
+ import { RecordExists } from '@owlmeans/resource'
6
+ import { AUTH_SESSION_MANAGER, AUTH_SESSION_RESOURCE, AUTH_SESSION_TTL } from './consts.js'
7
+ import { subjectId } from './identity.js'
8
+ import type {
9
+ AuthSessionDecision, AuthSessionManager, AuthSessionManagerOptions, AuthSessionSelector,
10
+ AuthSessionStoredRecord, AuthSessionSubject
11
+ } from './types.js'
12
+
13
+ const missing: AuthSessionDecision = { state: 'missing' }
14
+
15
+ type SessionResource = RedisResource<AuthSessionStoredRecord>
16
+
17
+ /**
18
+ * One Redis script owns each subject transition. A read-modify-save sequence lets competing
19
+ * grant/revoke operations overwrite a pending fence; this compares the fence id and increments
20
+ * the version in the same Redis command instead.
21
+ */
22
+ const transitionSubject = `
23
+ local current = redis.call('GET', KEYS[1])
24
+ local subject = current and cjson.decode(current) or cjson.decode(ARGV[1])
25
+ local now = tonumber(ARGV[2])
26
+ local expires = tonumber(ARGV[3])
27
+ local state = ARGV[4]
28
+ local operation = ARGV[5]
29
+ if tonumber(subject.expiresAt) <= now then
30
+ subject = cjson.decode(ARGV[1])
31
+ end
32
+ if state == 'pending' then
33
+ subject.state = 'pending'
34
+ subject.operationId = operation
35
+ else
36
+ if subject.state == 'pending' and subject.operationId ~= operation then
37
+ return -1
38
+ end
39
+ subject.state = state
40
+ subject.operationId = cjson.null
41
+ subject.version = tonumber(subject.version) + 1
42
+ end
43
+ subject.expiresAt = math.max(tonumber(subject.expiresAt), expires)
44
+ redis.call('SET', KEYS[1], cjson.encode(subject), 'PXAT', subject.expiresAt)
45
+ return tonumber(subject.version)
46
+ `
47
+
48
+ /** Extend only the subject's expiry without overwriting a concurrent fence or revocation. */
49
+ const extendSubjectExpiry = `
50
+ local current = redis.call('GET', KEYS[1])
51
+ if not current then return 0 end
52
+ local subject = cjson.decode(current)
53
+ local expires = tonumber(ARGV[1])
54
+ if tonumber(subject.expiresAt) < expires then
55
+ subject.expiresAt = expires
56
+ redis.call('SET', KEYS[1], cjson.encode(subject), 'PXAT', subject.expiresAt)
57
+ end
58
+ return 1
59
+ `
60
+
61
+ /** Redis-backed manager. Redis `create` is SET NX, so first session registration is atomic. */
62
+ export const makeRedisAuthSessionManager = (
63
+ resourceAlias: string = AUTH_SESSION_RESOURCE, options: AuthSessionManagerOptions = {}
64
+ ): AuthSessionManager => {
65
+ const now = options.now ?? Date.now
66
+ const resource = (context: BasicContext<any>): SessionResource =>
67
+ context.resource<SessionResource>(resourceAlias)
68
+
69
+ const loadSubject = async (ctx: BasicContext<any>, selector: AuthSessionSelector, at: number): Promise<AuthSessionSubject> => {
70
+ const id = subjectId(selector)
71
+ const found = await resource(ctx).load(id) as AuthSessionSubject | null
72
+ if (found != null && found.record === 'subject' && found.expiresAt > at) return found
73
+ const created: AuthSessionSubject = {
74
+ id, record: 'subject', entityId: selector.entityId, profileId: selector.profileId,
75
+ ...(selector.clientId != null ? { clientId: selector.clientId } : {}),
76
+ state: 'active', version: 1, expiresAt: at + AUTH_SESSION_TTL
77
+ }
78
+ try {
79
+ await resource(ctx).create(created, { ttl: new Date(created.expiresAt) })
80
+ return created
81
+ } catch (error) {
82
+ if (!(error instanceof RecordExists)) throw error
83
+ const raced = await resource(ctx).load(id) as AuthSessionSubject | null
84
+ if (raced == null || raced.record !== 'subject') throw error
85
+ return raced
86
+ }
87
+ }
88
+
89
+ const transition = async (
90
+ ctx: BasicContext<any>, selector: AuthSessionSelector,
91
+ state: 'pending' | 'active' | 'revoked', operationId: string
92
+ ): Promise<number> => {
93
+ const at = now()
94
+ const id = subjectId(selector)
95
+ const seed: AuthSessionSubject = {
96
+ id, record: 'subject', entityId: selector.entityId, profileId: selector.profileId,
97
+ ...(selector.clientId != null ? { clientId: selector.clientId } : {}),
98
+ state: 'active', version: 1, expiresAt: at + AUTH_SESSION_TTL
99
+ }
100
+ const expiresAt = at + AUTH_SESSION_TTL
101
+ const value = await resource(ctx).db.client.eval(
102
+ transitionSubject, 1, resource(ctx).key(id), JSON.stringify(seed), at, expiresAt, state, operationId
103
+ )
104
+ const version = Number(value)
105
+ if (!Number.isInteger(version) || version < 0) {
106
+ throw new Error('auth-session:transition-conflict')
107
+ }
108
+ return version
109
+ }
110
+
111
+ let service: AuthSessionManager
112
+ service = createService<AuthSessionManager>(options.alias ?? AUTH_SESSION_MANAGER, {
113
+ register: async session => {
114
+ const ctx = service.assertCtx()
115
+ const at = now()
116
+ // Do not trust a caller to choose the maximum session TTL. It may choose a shorter expiry.
117
+ const expiresAt = Math.min(session.expiresAt ?? at + AUTH_SESSION_TTL, at + AUTH_SESSION_TTL)
118
+ if (expiresAt <= at) return { state: 'expired' }
119
+ const subject = await loadSubject(ctx, session, at)
120
+ if (subject.state === 'pending') return { state: 'pending' }
121
+ if (subject.state === 'revoked') return { state: 'revoked' }
122
+ // Session records have their own absolute expiry. Keep the shared subject alive until the
123
+ // newest one expires, but use a script so an expiry extension cannot replace a concurrent
124
+ // pending/revoked state with this older active object.
125
+ if (subject.expiresAt < expiresAt) {
126
+ await resource(ctx).db.client.eval(
127
+ extendSubjectExpiry, 1, resource(ctx).key(subject.id), expiresAt
128
+ )
129
+ }
130
+ const stored = {
131
+ ...session, record: 'session' as const, issuedAt: session.issuedAt ?? at, expiresAt,
132
+ version: subject.version
133
+ }
134
+ try {
135
+ await resource(ctx).create(stored, { ttl: new Date(expiresAt) })
136
+ } catch (error) {
137
+ if (!(error instanceof RecordExists)) throw error
138
+ // A subject version may have advanced since its original session record was minted.
139
+ // A successful revalidation acknowledges that version without extending the absolute TTL.
140
+ const current = await resource(ctx).load(session.id)
141
+ if (current == null || current.record !== 'session') throw error
142
+ await resource(ctx).save({ ...stored, expiresAt: current.expiresAt }, { ttl: new Date(current.expiresAt) })
143
+ }
144
+ return { state: 'active', version: subject.version, expiresAt }
145
+ },
146
+ inspect: async id => {
147
+ const ctx = service.assertCtx()
148
+ const at = now()
149
+ const session = await resource(ctx).load(id)
150
+ if (session == null || session.record !== 'session') return missing
151
+ if (session.expiresAt <= at) return { state: 'expired' }
152
+ const subject = await resource(ctx).load(subjectId(session))
153
+ if (subject == null || subject.record !== 'subject' || subject.expiresAt <= at) return missing
154
+ if (subject.state === 'pending') return { state: 'pending' }
155
+ if (subject.state === 'revoked') return { state: 'revoked' }
156
+ return { state: subject.version > session.version ? 'refresh' : 'active', version: subject.version, expiresAt: session.expiresAt }
157
+ },
158
+ fence: async (selector, operationId) => {
159
+ const ctx = service.assertCtx()
160
+ await transition(ctx, selector, 'pending', operationId)
161
+ },
162
+ refresh: async (selector, operationId) => {
163
+ const ctx = service.assertCtx()
164
+ return await transition(ctx, selector, 'active', operationId)
165
+ },
166
+ revoke: async (selector, operationId) => {
167
+ const ctx = service.assertCtx()
168
+ return await transition(ctx, selector, 'revoked', operationId)
169
+ }
170
+ })
171
+ return service
172
+ }
173
+
174
+ export const appendRedisAuthSessionManager = <C extends BasicContext<any>>(
175
+ context: C, options?: AuthSessionManagerOptions & { resourceAlias?: string, dbAlias?: string, serviceAlias?: string }
176
+ ): C => {
177
+ const resourceAlias = options?.resourceAlias ?? AUTH_SESSION_RESOURCE
178
+ if (!context.hasResource(resourceAlias)) {
179
+ context.registerResource(makeRedisResource<AuthSessionStoredRecord>(resourceAlias, options?.dbAlias, options?.serviceAlias))
180
+ }
181
+ const alias = options?.alias ?? AUTH_SESSION_MANAGER
182
+ if (!context.hasService(alias)) context.registerService(makeRedisAuthSessionManager(resourceAlias, options))
183
+ return context
184
+ }
185
+
186
+ export type { RedisResource }
package/src/types.ts ADDED
@@ -0,0 +1,60 @@
1
+ import type { InitializedService } from '@owlmeans/context'
2
+ import type { ResourceRecord } from '@owlmeans/resource'
3
+
4
+ export type AuthSessionKind = 'bearer' | 'oidc-access' | 'oidc-refresh' | 'oidc-provider-session'
5
+
6
+ export type AuthSessionState = 'active' | 'pending' | 'refresh' | 'revoked'
7
+
8
+ /** Stable storage selector. `entityId` never travels in an authentication envelope. */
9
+ export interface AuthSessionSelector {
10
+ entityId: string
11
+ profileId: string
12
+ clientId?: string
13
+ }
14
+
15
+ export interface AuthSessionRecord extends ResourceRecord, AuthSessionSelector {
16
+ id: string
17
+ record: 'session'
18
+ kind: AuthSessionKind
19
+ version: number
20
+ issuedAt: number
21
+ expiresAt: number
22
+ }
23
+
24
+ export interface AuthSessionSubject extends ResourceRecord, AuthSessionSelector {
25
+ id: string
26
+ record: 'subject'
27
+ state: AuthSessionState
28
+ version: number
29
+ expiresAt: number
30
+ operationId?: string
31
+ }
32
+
33
+ export type AuthSessionStoredRecord = AuthSessionRecord | AuthSessionSubject
34
+
35
+ export type AuthSessionDecision =
36
+ | { state: 'active' | 'refresh'; version: number; expiresAt: number }
37
+ | { state: 'pending' | 'revoked' | 'missing' | 'expired' }
38
+
39
+ export interface RegisterAuthSession extends AuthSessionSelector {
40
+ id: string
41
+ kind: AuthSessionKind
42
+ issuedAt?: number
43
+ expiresAt?: number
44
+ }
45
+
46
+ export interface AuthSessionManager extends InitializedService {
47
+ register: (session: RegisterAuthSession) => Promise<AuthSessionDecision>
48
+ inspect: (id: string) => Promise<AuthSessionDecision>
49
+ /** Fences all sessions for a profile/client before its authority is changed. */
50
+ fence: (selector: AuthSessionSelector, operationId: string) => Promise<void>
51
+ /** Completes a fence as a claim refresh. */
52
+ refresh: (selector: AuthSessionSelector, operationId: string) => Promise<number>
53
+ /** Completes a fence as a session revocation. */
54
+ revoke: (selector: AuthSessionSelector, operationId: string) => Promise<number>
55
+ }
56
+
57
+ export interface AuthSessionManagerOptions {
58
+ alias?: string
59
+ now?: () => number
60
+ }
@@ -0,0 +1,48 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { makeMemoryAuthSessionManager } from '../src/memory.js'
3
+
4
+ describe('memory auth session manager', () => {
5
+ test('refreshes and revokes an existing seven-day session', async () => {
6
+ let now = 1_000
7
+ const manager = makeMemoryAuthSessionManager({ now: () => now })
8
+ await manager.init()
9
+ const selector = { entityId: 'entity-1', profileId: 'profile-1', clientId: 'client-1' }
10
+ const issued = await manager.register({ ...selector, id: 'session-1', kind: 'bearer' })
11
+ expect(issued.state).toBe('active')
12
+ await manager.fence(selector, 'change-1')
13
+ expect((await manager.inspect('session-1')).state).toBe('pending')
14
+ await manager.refresh(selector, 'change-1')
15
+ expect((await manager.inspect('session-1')).state).toBe('refresh')
16
+ await manager.revoke(selector, 'change-2')
17
+ expect((await manager.inspect('session-1')).state).toBe('revoked')
18
+ now += 7 * 24 * 60 * 60 * 1000 + 1
19
+ expect((await manager.inspect('session-1')).state).toBe('expired')
20
+ })
21
+
22
+ test('caps a caller-supplied expiry at seven days', async () => {
23
+ const now = 2_000
24
+ const manager = makeMemoryAuthSessionManager({ now: () => now })
25
+ await manager.init()
26
+
27
+ const issued = await manager.register({
28
+ id: 'session-cap', kind: 'bearer', entityId: 'entity-1', profileId: 'profile-1',
29
+ expiresAt: now + 30 * 24 * 60 * 60 * 1000,
30
+ })
31
+
32
+ expect(issued).toMatchObject({ state: 'active', expiresAt: now + 7 * 24 * 60 * 60 * 1000 })
33
+ })
34
+
35
+ test('keeps the shared subject until a later independently bounded session expires', async () => {
36
+ let now = 5_000
37
+ const manager = makeMemoryAuthSessionManager({ now: () => now })
38
+ await manager.init()
39
+ const selector = { entityId: 'entity-1', profileId: 'profile-1' }
40
+ await manager.register({ ...selector, id: 'first', kind: 'bearer' })
41
+
42
+ now += 6 * 24 * 60 * 60 * 1000
43
+ await manager.register({ ...selector, id: 'second', kind: 'bearer' })
44
+
45
+ now += 24 * 60 * 60 * 1000 + 1
46
+ expect((await manager.inspect('second')).state).toBe('active')
47
+ })
48
+ })
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": [
3
+ "@owlmeans/dep-config/tsconfig.base.json",
4
+ "@owlmeans/dep-config/tsconfig.node.json"
5
+ ],
6
+ "compilerOptions": {
7
+ "types": ["bun"],
8
+ "rootDir": "../",
9
+ "noEmit": true
10
+ },
11
+ "include": ["./**/*", "../src/**/*"]
12
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": [
3
+ "@owlmeans/dep-config/tsconfig.base.json",
4
+ "@owlmeans/dep-config/tsconfig.node.json"
5
+ ],
6
+ "compilerOptions": {
7
+ "rootDir": "./src/",
8
+ "outDir": "./build/"
9
+ },
10
+ "exclude": ["./build/**/*", "./tests/**/*", "./*.ts"]
11
+ }