@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 +45 -0
- package/agent-meta/manifest.json +16 -0
- package/agent-meta/skills/server-auth-session/SKILL.md +42 -0
- package/build/consts.d.ts +5 -0
- package/build/consts.d.ts.map +1 -0
- package/build/consts.js +5 -0
- package/build/consts.js.map +1 -0
- package/build/identity.d.ts +4 -0
- package/build/identity.d.ts.map +1 -0
- package/build/identity.js +3 -0
- package/build/identity.js.map +1 -0
- package/build/index.d.ts +5 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +4 -0
- package/build/index.js.map +1 -0
- package/build/memory.d.ts +9 -0
- package/build/memory.d.ts.map +1 -0
- package/build/memory.js +103 -0
- package/build/memory.js.map +1 -0
- package/build/redis.d.ts +12 -0
- package/build/redis.d.ts.map +1 -0
- package/build/redis.js +173 -0
- package/build/redis.js.map +1 -0
- package/build/types.d.ts +55 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/package.json +37 -0
- package/src/consts.ts +6 -0
- package/src/identity.ts +5 -0
- package/src/index.ts +4 -0
- package/src/memory.ts +107 -0
- package/src/redis.ts +186 -0
- package/src/types.ts +60 -0
- package/tests/service.spec.ts +48 -0
- package/tests/tsconfig.json +12 -0
- package/tsconfig.json +11 -0
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"}
|
package/build/consts.js
ADDED
|
@@ -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 @@
|
|
|
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"}
|
package/build/index.d.ts
ADDED
|
@@ -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 @@
|
|
|
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"}
|
package/build/memory.js
ADDED
|
@@ -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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"}
|
package/build/redis.d.ts
ADDED
|
@@ -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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"}
|
package/build/types.d.ts
ADDED
|
@@ -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 @@
|
|
|
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
package/src/identity.ts
ADDED
|
@@ -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
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
|
+
})
|
package/tsconfig.json
ADDED