@aooth/auth 0.1.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/LICENSE +21 -0
- package/README.md +40 -0
- package/dist/atscript-db.cjs +113 -0
- package/dist/atscript-db.d.cts +85 -0
- package/dist/atscript-db.d.mts +85 -0
- package/dist/atscript-db.mjs +112 -0
- package/dist/index.cjs +741 -0
- package/dist/index.d.cts +386 -0
- package/dist/index.d.mts +386 -0
- package/dist/index.mjs +733 -0
- package/dist/redis.cjs +142 -0
- package/dist/redis.d.cts +91 -0
- package/dist/redis.d.mts +91 -0
- package/dist/redis.mjs +140 -0
- package/dist/store-B1t8KkfA.d.mts +124 -0
- package/dist/store-untAtWQz.d.cts +124 -0
- package/package.json +75 -0
- package/src/atscript-db/auth-credential.as +44 -0
package/dist/redis.cjs
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let node_crypto = require("node:crypto");
|
|
3
|
+
//#region src/redis/index.ts
|
|
4
|
+
/**
|
|
5
|
+
* Redis-backed `CredentialStore`. Token id is a random UUID (the same
|
|
6
|
+
* scheme as `CredentialStoreMemory`) — the token is opaque to the client.
|
|
7
|
+
*
|
|
8
|
+
* Storage shape:
|
|
9
|
+
* - `<prefix>:t:<token>` → JSON-serialised `CredentialState`
|
|
10
|
+
* - `<prefix>:u:<userId>` → SET of tokens for that user (for
|
|
11
|
+
* `revokeAllForUser` and `listForUser`)
|
|
12
|
+
*
|
|
13
|
+
* TTL: when `persist(state, ttl)` is called, the state key gets a `PX` TTL
|
|
14
|
+
* so Redis evicts it on expiry. The user-index set is NOT TTL-bounded — we
|
|
15
|
+
* lazily prune dead members on `listForUser` and `revokeAllForUser`.
|
|
16
|
+
*/
|
|
17
|
+
var CredentialStoreRedis = class {
|
|
18
|
+
redis;
|
|
19
|
+
prefix;
|
|
20
|
+
constructor(opts) {
|
|
21
|
+
this.redis = opts.redis;
|
|
22
|
+
this.prefix = opts.prefix ?? "aooth:cred";
|
|
23
|
+
}
|
|
24
|
+
tokenKey(token) {
|
|
25
|
+
return `${this.prefix}:t:${token}`;
|
|
26
|
+
}
|
|
27
|
+
userKey(userId) {
|
|
28
|
+
return `${this.prefix}:u:${userId}`;
|
|
29
|
+
}
|
|
30
|
+
async persist(state, ttl) {
|
|
31
|
+
const token = (0, node_crypto.randomUUID)();
|
|
32
|
+
const stored = { ...state };
|
|
33
|
+
if (typeof ttl === "number") stored.expiresAt = Date.now() + ttl;
|
|
34
|
+
const ttlMs = Math.max(0, stored.expiresAt - Date.now());
|
|
35
|
+
if (ttlMs <= 0) throw new Error("CredentialStoreRedis.persist: refusing to persist an already-expired credential");
|
|
36
|
+
await this.redis.set(this.tokenKey(token), JSON.stringify(stored), "PX", ttlMs);
|
|
37
|
+
await this.redis.sadd(this.userKey(state.userId), token);
|
|
38
|
+
return token;
|
|
39
|
+
}
|
|
40
|
+
async retrieve(token) {
|
|
41
|
+
const raw = await this.redis.get(this.tokenKey(token));
|
|
42
|
+
if (raw === null) return null;
|
|
43
|
+
const state = JSON.parse(raw);
|
|
44
|
+
if (state.expiresAt <= Date.now()) {
|
|
45
|
+
await this.redis.del(this.tokenKey(token));
|
|
46
|
+
await this.redis.srem(this.userKey(state.userId), token);
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
return state;
|
|
50
|
+
}
|
|
51
|
+
async consume(token) {
|
|
52
|
+
const state = await this.retrieve(token);
|
|
53
|
+
if (!state) return null;
|
|
54
|
+
await this.revoke(token);
|
|
55
|
+
return state;
|
|
56
|
+
}
|
|
57
|
+
async update(token, state) {
|
|
58
|
+
const existingRaw = await this.redis.get(this.tokenKey(token));
|
|
59
|
+
if (existingRaw === null) return token;
|
|
60
|
+
const existing = JSON.parse(existingRaw);
|
|
61
|
+
if (existing.userId !== state.userId) {
|
|
62
|
+
await this.redis.srem(this.userKey(existing.userId), token);
|
|
63
|
+
await this.redis.sadd(this.userKey(state.userId), token);
|
|
64
|
+
}
|
|
65
|
+
const ttlMs = Math.max(0, state.expiresAt - Date.now());
|
|
66
|
+
if (ttlMs > 0) await this.redis.set(this.tokenKey(token), JSON.stringify(state), "PX", ttlMs);
|
|
67
|
+
else await this.revoke(token);
|
|
68
|
+
return token;
|
|
69
|
+
}
|
|
70
|
+
async revoke(token) {
|
|
71
|
+
const raw = await this.redis.get(this.tokenKey(token));
|
|
72
|
+
if (raw === null) return;
|
|
73
|
+
const state = JSON.parse(raw);
|
|
74
|
+
await this.redis.del(this.tokenKey(token));
|
|
75
|
+
await this.redis.srem(this.userKey(state.userId), token);
|
|
76
|
+
}
|
|
77
|
+
async revokeAllForUser(userId) {
|
|
78
|
+
const setKey = this.userKey(userId);
|
|
79
|
+
const tokens = await this.redis.smembers(setKey);
|
|
80
|
+
if (tokens.length === 0) return 0;
|
|
81
|
+
const keys = tokens.map((t) => this.tokenKey(t));
|
|
82
|
+
const removed = await this.redis.del(...keys);
|
|
83
|
+
await this.redis.srem(setKey, ...tokens);
|
|
84
|
+
return removed;
|
|
85
|
+
}
|
|
86
|
+
async listForUser(userId) {
|
|
87
|
+
const setKey = this.userKey(userId);
|
|
88
|
+
const tokens = await this.redis.smembers(setKey);
|
|
89
|
+
if (tokens.length === 0) return [];
|
|
90
|
+
const now = Date.now();
|
|
91
|
+
const out = [];
|
|
92
|
+
const dead = [];
|
|
93
|
+
for (const token of tokens) {
|
|
94
|
+
const raw = await this.redis.get(this.tokenKey(token));
|
|
95
|
+
if (raw === null) {
|
|
96
|
+
dead.push(token);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const state = JSON.parse(raw);
|
|
100
|
+
if (state.expiresAt <= now) {
|
|
101
|
+
dead.push(token);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
out.push({
|
|
105
|
+
...state,
|
|
106
|
+
token
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
if (dead.length > 0) await this.redis.srem(setKey, ...dead);
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
/**
|
|
114
|
+
* Redis-backed `DenylistStore`. Each `add` writes a key with a `PX` TTL set
|
|
115
|
+
* to `expiresAt - now`, so Redis self-evicts the entry once the underlying
|
|
116
|
+
* token would have expired anyway. `cleanup` is a no-op for that reason.
|
|
117
|
+
*/
|
|
118
|
+
var DenylistStoreRedis = class {
|
|
119
|
+
redis;
|
|
120
|
+
prefix;
|
|
121
|
+
constructor(opts) {
|
|
122
|
+
this.redis = opts.redis;
|
|
123
|
+
this.prefix = opts.prefix ?? "aooth:dl";
|
|
124
|
+
}
|
|
125
|
+
key(jti) {
|
|
126
|
+
return `${this.prefix}:${jti}`;
|
|
127
|
+
}
|
|
128
|
+
async add(jti, expiresAt) {
|
|
129
|
+
const ttlMs = expiresAt - Date.now();
|
|
130
|
+
if (ttlMs <= 0) return;
|
|
131
|
+
await this.redis.set(this.key(jti), "1", "PX", ttlMs);
|
|
132
|
+
}
|
|
133
|
+
async has(jti) {
|
|
134
|
+
return await this.redis.exists(this.key(jti)) > 0;
|
|
135
|
+
}
|
|
136
|
+
async cleanup() {
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
//#endregion
|
|
141
|
+
exports.CredentialStoreRedis = CredentialStoreRedis;
|
|
142
|
+
exports.DenylistStoreRedis = DenylistStoreRedis;
|
package/dist/redis.d.cts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { a as CredentialState, n as DenylistStore, t as CredentialStore } from "./store-untAtWQz.cjs";
|
|
2
|
+
|
|
3
|
+
//#region src/redis/index.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Structural Redis client. Covers the exact set of commands used by
|
|
6
|
+
* `CredentialStoreRedis` and `DenylistStoreRedis` — no more.
|
|
7
|
+
*
|
|
8
|
+
* Compatible by-shape with `ioredis`, `redis@4+`, `@redis/client`, and ad-hoc
|
|
9
|
+
* test doubles. Consumers are free to wrap whatever client they ship; we
|
|
10
|
+
* deliberately do not declare a peer dep on any specific package.
|
|
11
|
+
*
|
|
12
|
+
* Return types are widened to the union of what real clients return:
|
|
13
|
+
* - `set` returns `'OK' | string | null` (null on conditional sets that fail)
|
|
14
|
+
* - `del` / `expire` / `exists` / `sadd` / `srem` return `number`
|
|
15
|
+
* - `get` returns `string | null`
|
|
16
|
+
* - `smembers` returns `string[]`
|
|
17
|
+
*
|
|
18
|
+
* The `ttl` arg passed to `set` is in MILLISECONDS — we always call with
|
|
19
|
+
* `PX`, never `EX`, so callers don't have to translate.
|
|
20
|
+
*/
|
|
21
|
+
interface RedisLike {
|
|
22
|
+
/** `SET key value [PX ms]` — accepts an optional ms TTL. */
|
|
23
|
+
set(key: string, value: string, mode?: "PX", ttlMs?: number): Promise<string | null>;
|
|
24
|
+
get(key: string): Promise<string | null>;
|
|
25
|
+
del(...keys: string[]): Promise<number>;
|
|
26
|
+
exists(key: string): Promise<number>;
|
|
27
|
+
/** `PEXPIRE key ttlMs` — ms TTL on an existing key. */
|
|
28
|
+
expire(key: string, ttlMs: number): Promise<number>;
|
|
29
|
+
sadd(key: string, ...members: string[]): Promise<number>;
|
|
30
|
+
srem(key: string, ...members: string[]): Promise<number>;
|
|
31
|
+
smembers(key: string): Promise<string[]>;
|
|
32
|
+
}
|
|
33
|
+
interface RedisCredentialStoreOptions {
|
|
34
|
+
redis: RedisLike;
|
|
35
|
+
/**
|
|
36
|
+
* Key prefix for every stored entry. Defaults to `aooth:cred`. Combined
|
|
37
|
+
* with the token id (`<prefix>:t:<token>`) and per-user index set
|
|
38
|
+
* (`<prefix>:u:<userId>`).
|
|
39
|
+
*/
|
|
40
|
+
prefix?: string;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Redis-backed `CredentialStore`. Token id is a random UUID (the same
|
|
44
|
+
* scheme as `CredentialStoreMemory`) — the token is opaque to the client.
|
|
45
|
+
*
|
|
46
|
+
* Storage shape:
|
|
47
|
+
* - `<prefix>:t:<token>` → JSON-serialised `CredentialState`
|
|
48
|
+
* - `<prefix>:u:<userId>` → SET of tokens for that user (for
|
|
49
|
+
* `revokeAllForUser` and `listForUser`)
|
|
50
|
+
*
|
|
51
|
+
* TTL: when `persist(state, ttl)` is called, the state key gets a `PX` TTL
|
|
52
|
+
* so Redis evicts it on expiry. The user-index set is NOT TTL-bounded — we
|
|
53
|
+
* lazily prune dead members on `listForUser` and `revokeAllForUser`.
|
|
54
|
+
*/
|
|
55
|
+
declare class CredentialStoreRedis<TClaims extends object = object> implements CredentialStore<TClaims> {
|
|
56
|
+
private readonly redis;
|
|
57
|
+
private readonly prefix;
|
|
58
|
+
constructor(opts: RedisCredentialStoreOptions);
|
|
59
|
+
private tokenKey;
|
|
60
|
+
private userKey;
|
|
61
|
+
persist(state: CredentialState<TClaims>, ttl?: number): Promise<string>;
|
|
62
|
+
retrieve(token: string): Promise<CredentialState<TClaims> | null>;
|
|
63
|
+
consume(token: string): Promise<CredentialState<TClaims> | null>;
|
|
64
|
+
update(token: string, state: CredentialState<TClaims>): Promise<string>;
|
|
65
|
+
revoke(token: string): Promise<void>;
|
|
66
|
+
revokeAllForUser(userId: string): Promise<number>;
|
|
67
|
+
listForUser(userId: string): Promise<Array<CredentialState<TClaims> & {
|
|
68
|
+
token: string;
|
|
69
|
+
}>>;
|
|
70
|
+
}
|
|
71
|
+
interface RedisDenylistStoreOptions {
|
|
72
|
+
redis: RedisLike;
|
|
73
|
+
/** Key prefix. Defaults to `aooth:dl`. Combined as `<prefix>:<jti>`. */
|
|
74
|
+
prefix?: string;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Redis-backed `DenylistStore`. Each `add` writes a key with a `PX` TTL set
|
|
78
|
+
* to `expiresAt - now`, so Redis self-evicts the entry once the underlying
|
|
79
|
+
* token would have expired anyway. `cleanup` is a no-op for that reason.
|
|
80
|
+
*/
|
|
81
|
+
declare class DenylistStoreRedis implements DenylistStore {
|
|
82
|
+
private readonly redis;
|
|
83
|
+
private readonly prefix;
|
|
84
|
+
constructor(opts: RedisDenylistStoreOptions);
|
|
85
|
+
private key;
|
|
86
|
+
add(jti: string, expiresAt: number): Promise<void>;
|
|
87
|
+
has(jti: string): Promise<boolean>;
|
|
88
|
+
cleanup(): Promise<number>;
|
|
89
|
+
}
|
|
90
|
+
//#endregion
|
|
91
|
+
export { CredentialStoreRedis, DenylistStoreRedis, RedisLike };
|
package/dist/redis.d.mts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { a as CredentialState, n as DenylistStore, t as CredentialStore } from "./store-B1t8KkfA.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/redis/index.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Structural Redis client. Covers the exact set of commands used by
|
|
6
|
+
* `CredentialStoreRedis` and `DenylistStoreRedis` — no more.
|
|
7
|
+
*
|
|
8
|
+
* Compatible by-shape with `ioredis`, `redis@4+`, `@redis/client`, and ad-hoc
|
|
9
|
+
* test doubles. Consumers are free to wrap whatever client they ship; we
|
|
10
|
+
* deliberately do not declare a peer dep on any specific package.
|
|
11
|
+
*
|
|
12
|
+
* Return types are widened to the union of what real clients return:
|
|
13
|
+
* - `set` returns `'OK' | string | null` (null on conditional sets that fail)
|
|
14
|
+
* - `del` / `expire` / `exists` / `sadd` / `srem` return `number`
|
|
15
|
+
* - `get` returns `string | null`
|
|
16
|
+
* - `smembers` returns `string[]`
|
|
17
|
+
*
|
|
18
|
+
* The `ttl` arg passed to `set` is in MILLISECONDS — we always call with
|
|
19
|
+
* `PX`, never `EX`, so callers don't have to translate.
|
|
20
|
+
*/
|
|
21
|
+
interface RedisLike {
|
|
22
|
+
/** `SET key value [PX ms]` — accepts an optional ms TTL. */
|
|
23
|
+
set(key: string, value: string, mode?: "PX", ttlMs?: number): Promise<string | null>;
|
|
24
|
+
get(key: string): Promise<string | null>;
|
|
25
|
+
del(...keys: string[]): Promise<number>;
|
|
26
|
+
exists(key: string): Promise<number>;
|
|
27
|
+
/** `PEXPIRE key ttlMs` — ms TTL on an existing key. */
|
|
28
|
+
expire(key: string, ttlMs: number): Promise<number>;
|
|
29
|
+
sadd(key: string, ...members: string[]): Promise<number>;
|
|
30
|
+
srem(key: string, ...members: string[]): Promise<number>;
|
|
31
|
+
smembers(key: string): Promise<string[]>;
|
|
32
|
+
}
|
|
33
|
+
interface RedisCredentialStoreOptions {
|
|
34
|
+
redis: RedisLike;
|
|
35
|
+
/**
|
|
36
|
+
* Key prefix for every stored entry. Defaults to `aooth:cred`. Combined
|
|
37
|
+
* with the token id (`<prefix>:t:<token>`) and per-user index set
|
|
38
|
+
* (`<prefix>:u:<userId>`).
|
|
39
|
+
*/
|
|
40
|
+
prefix?: string;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Redis-backed `CredentialStore`. Token id is a random UUID (the same
|
|
44
|
+
* scheme as `CredentialStoreMemory`) — the token is opaque to the client.
|
|
45
|
+
*
|
|
46
|
+
* Storage shape:
|
|
47
|
+
* - `<prefix>:t:<token>` → JSON-serialised `CredentialState`
|
|
48
|
+
* - `<prefix>:u:<userId>` → SET of tokens for that user (for
|
|
49
|
+
* `revokeAllForUser` and `listForUser`)
|
|
50
|
+
*
|
|
51
|
+
* TTL: when `persist(state, ttl)` is called, the state key gets a `PX` TTL
|
|
52
|
+
* so Redis evicts it on expiry. The user-index set is NOT TTL-bounded — we
|
|
53
|
+
* lazily prune dead members on `listForUser` and `revokeAllForUser`.
|
|
54
|
+
*/
|
|
55
|
+
declare class CredentialStoreRedis<TClaims extends object = object> implements CredentialStore<TClaims> {
|
|
56
|
+
private readonly redis;
|
|
57
|
+
private readonly prefix;
|
|
58
|
+
constructor(opts: RedisCredentialStoreOptions);
|
|
59
|
+
private tokenKey;
|
|
60
|
+
private userKey;
|
|
61
|
+
persist(state: CredentialState<TClaims>, ttl?: number): Promise<string>;
|
|
62
|
+
retrieve(token: string): Promise<CredentialState<TClaims> | null>;
|
|
63
|
+
consume(token: string): Promise<CredentialState<TClaims> | null>;
|
|
64
|
+
update(token: string, state: CredentialState<TClaims>): Promise<string>;
|
|
65
|
+
revoke(token: string): Promise<void>;
|
|
66
|
+
revokeAllForUser(userId: string): Promise<number>;
|
|
67
|
+
listForUser(userId: string): Promise<Array<CredentialState<TClaims> & {
|
|
68
|
+
token: string;
|
|
69
|
+
}>>;
|
|
70
|
+
}
|
|
71
|
+
interface RedisDenylistStoreOptions {
|
|
72
|
+
redis: RedisLike;
|
|
73
|
+
/** Key prefix. Defaults to `aooth:dl`. Combined as `<prefix>:<jti>`. */
|
|
74
|
+
prefix?: string;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Redis-backed `DenylistStore`. Each `add` writes a key with a `PX` TTL set
|
|
78
|
+
* to `expiresAt - now`, so Redis self-evicts the entry once the underlying
|
|
79
|
+
* token would have expired anyway. `cleanup` is a no-op for that reason.
|
|
80
|
+
*/
|
|
81
|
+
declare class DenylistStoreRedis implements DenylistStore {
|
|
82
|
+
private readonly redis;
|
|
83
|
+
private readonly prefix;
|
|
84
|
+
constructor(opts: RedisDenylistStoreOptions);
|
|
85
|
+
private key;
|
|
86
|
+
add(jti: string, expiresAt: number): Promise<void>;
|
|
87
|
+
has(jti: string): Promise<boolean>;
|
|
88
|
+
cleanup(): Promise<number>;
|
|
89
|
+
}
|
|
90
|
+
//#endregion
|
|
91
|
+
export { CredentialStoreRedis, DenylistStoreRedis, RedisLike };
|
package/dist/redis.mjs
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
//#region src/redis/index.ts
|
|
3
|
+
/**
|
|
4
|
+
* Redis-backed `CredentialStore`. Token id is a random UUID (the same
|
|
5
|
+
* scheme as `CredentialStoreMemory`) — the token is opaque to the client.
|
|
6
|
+
*
|
|
7
|
+
* Storage shape:
|
|
8
|
+
* - `<prefix>:t:<token>` → JSON-serialised `CredentialState`
|
|
9
|
+
* - `<prefix>:u:<userId>` → SET of tokens for that user (for
|
|
10
|
+
* `revokeAllForUser` and `listForUser`)
|
|
11
|
+
*
|
|
12
|
+
* TTL: when `persist(state, ttl)` is called, the state key gets a `PX` TTL
|
|
13
|
+
* so Redis evicts it on expiry. The user-index set is NOT TTL-bounded — we
|
|
14
|
+
* lazily prune dead members on `listForUser` and `revokeAllForUser`.
|
|
15
|
+
*/
|
|
16
|
+
var CredentialStoreRedis = class {
|
|
17
|
+
redis;
|
|
18
|
+
prefix;
|
|
19
|
+
constructor(opts) {
|
|
20
|
+
this.redis = opts.redis;
|
|
21
|
+
this.prefix = opts.prefix ?? "aooth:cred";
|
|
22
|
+
}
|
|
23
|
+
tokenKey(token) {
|
|
24
|
+
return `${this.prefix}:t:${token}`;
|
|
25
|
+
}
|
|
26
|
+
userKey(userId) {
|
|
27
|
+
return `${this.prefix}:u:${userId}`;
|
|
28
|
+
}
|
|
29
|
+
async persist(state, ttl) {
|
|
30
|
+
const token = randomUUID();
|
|
31
|
+
const stored = { ...state };
|
|
32
|
+
if (typeof ttl === "number") stored.expiresAt = Date.now() + ttl;
|
|
33
|
+
const ttlMs = Math.max(0, stored.expiresAt - Date.now());
|
|
34
|
+
if (ttlMs <= 0) throw new Error("CredentialStoreRedis.persist: refusing to persist an already-expired credential");
|
|
35
|
+
await this.redis.set(this.tokenKey(token), JSON.stringify(stored), "PX", ttlMs);
|
|
36
|
+
await this.redis.sadd(this.userKey(state.userId), token);
|
|
37
|
+
return token;
|
|
38
|
+
}
|
|
39
|
+
async retrieve(token) {
|
|
40
|
+
const raw = await this.redis.get(this.tokenKey(token));
|
|
41
|
+
if (raw === null) return null;
|
|
42
|
+
const state = JSON.parse(raw);
|
|
43
|
+
if (state.expiresAt <= Date.now()) {
|
|
44
|
+
await this.redis.del(this.tokenKey(token));
|
|
45
|
+
await this.redis.srem(this.userKey(state.userId), token);
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
return state;
|
|
49
|
+
}
|
|
50
|
+
async consume(token) {
|
|
51
|
+
const state = await this.retrieve(token);
|
|
52
|
+
if (!state) return null;
|
|
53
|
+
await this.revoke(token);
|
|
54
|
+
return state;
|
|
55
|
+
}
|
|
56
|
+
async update(token, state) {
|
|
57
|
+
const existingRaw = await this.redis.get(this.tokenKey(token));
|
|
58
|
+
if (existingRaw === null) return token;
|
|
59
|
+
const existing = JSON.parse(existingRaw);
|
|
60
|
+
if (existing.userId !== state.userId) {
|
|
61
|
+
await this.redis.srem(this.userKey(existing.userId), token);
|
|
62
|
+
await this.redis.sadd(this.userKey(state.userId), token);
|
|
63
|
+
}
|
|
64
|
+
const ttlMs = Math.max(0, state.expiresAt - Date.now());
|
|
65
|
+
if (ttlMs > 0) await this.redis.set(this.tokenKey(token), JSON.stringify(state), "PX", ttlMs);
|
|
66
|
+
else await this.revoke(token);
|
|
67
|
+
return token;
|
|
68
|
+
}
|
|
69
|
+
async revoke(token) {
|
|
70
|
+
const raw = await this.redis.get(this.tokenKey(token));
|
|
71
|
+
if (raw === null) return;
|
|
72
|
+
const state = JSON.parse(raw);
|
|
73
|
+
await this.redis.del(this.tokenKey(token));
|
|
74
|
+
await this.redis.srem(this.userKey(state.userId), token);
|
|
75
|
+
}
|
|
76
|
+
async revokeAllForUser(userId) {
|
|
77
|
+
const setKey = this.userKey(userId);
|
|
78
|
+
const tokens = await this.redis.smembers(setKey);
|
|
79
|
+
if (tokens.length === 0) return 0;
|
|
80
|
+
const keys = tokens.map((t) => this.tokenKey(t));
|
|
81
|
+
const removed = await this.redis.del(...keys);
|
|
82
|
+
await this.redis.srem(setKey, ...tokens);
|
|
83
|
+
return removed;
|
|
84
|
+
}
|
|
85
|
+
async listForUser(userId) {
|
|
86
|
+
const setKey = this.userKey(userId);
|
|
87
|
+
const tokens = await this.redis.smembers(setKey);
|
|
88
|
+
if (tokens.length === 0) return [];
|
|
89
|
+
const now = Date.now();
|
|
90
|
+
const out = [];
|
|
91
|
+
const dead = [];
|
|
92
|
+
for (const token of tokens) {
|
|
93
|
+
const raw = await this.redis.get(this.tokenKey(token));
|
|
94
|
+
if (raw === null) {
|
|
95
|
+
dead.push(token);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const state = JSON.parse(raw);
|
|
99
|
+
if (state.expiresAt <= now) {
|
|
100
|
+
dead.push(token);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
out.push({
|
|
104
|
+
...state,
|
|
105
|
+
token
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
if (dead.length > 0) await this.redis.srem(setKey, ...dead);
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* Redis-backed `DenylistStore`. Each `add` writes a key with a `PX` TTL set
|
|
114
|
+
* to `expiresAt - now`, so Redis self-evicts the entry once the underlying
|
|
115
|
+
* token would have expired anyway. `cleanup` is a no-op for that reason.
|
|
116
|
+
*/
|
|
117
|
+
var DenylistStoreRedis = class {
|
|
118
|
+
redis;
|
|
119
|
+
prefix;
|
|
120
|
+
constructor(opts) {
|
|
121
|
+
this.redis = opts.redis;
|
|
122
|
+
this.prefix = opts.prefix ?? "aooth:dl";
|
|
123
|
+
}
|
|
124
|
+
key(jti) {
|
|
125
|
+
return `${this.prefix}:${jti}`;
|
|
126
|
+
}
|
|
127
|
+
async add(jti, expiresAt) {
|
|
128
|
+
const ttlMs = expiresAt - Date.now();
|
|
129
|
+
if (ttlMs <= 0) return;
|
|
130
|
+
await this.redis.set(this.key(jti), "1", "PX", ttlMs);
|
|
131
|
+
}
|
|
132
|
+
async has(jti) {
|
|
133
|
+
return await this.redis.exists(this.key(jti)) > 0;
|
|
134
|
+
}
|
|
135
|
+
async cleanup() {
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
//#endregion
|
|
140
|
+
export { CredentialStoreRedis, DenylistStoreRedis };
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
//#region src/credential/types.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* What a successful auth check produces.
|
|
4
|
+
* Generic over TClaims for typed custom claims.
|
|
5
|
+
*/
|
|
6
|
+
interface AuthContext<TClaims extends object = object> {
|
|
7
|
+
userId: string;
|
|
8
|
+
method: "session" | "token";
|
|
9
|
+
credentialId: string;
|
|
10
|
+
expiresAt: number;
|
|
11
|
+
claims?: TClaims;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Display metadata for stateful credentials.
|
|
15
|
+
* Extensible via TypeScript declaration merging:
|
|
16
|
+
* declare module '@aooth/auth' {
|
|
17
|
+
* interface CredentialMetadata { geoCountry?: string }
|
|
18
|
+
* }
|
|
19
|
+
*/
|
|
20
|
+
interface CredentialMetadata {
|
|
21
|
+
ip?: string;
|
|
22
|
+
userAgent?: string;
|
|
23
|
+
fingerprint?: string;
|
|
24
|
+
label?: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Persisted state of a credential.
|
|
28
|
+
*/
|
|
29
|
+
interface CredentialState<TClaims extends object = object> {
|
|
30
|
+
userId: string;
|
|
31
|
+
issuedAt: number;
|
|
32
|
+
expiresAt: number;
|
|
33
|
+
claims?: TClaims;
|
|
34
|
+
metadata?: CredentialMetadata;
|
|
35
|
+
/**
|
|
36
|
+
* Discriminant between access and refresh credentials persisted in the same store.
|
|
37
|
+
* Defaults to "access" when omitted.
|
|
38
|
+
*/
|
|
39
|
+
kind?: "access" | "refresh";
|
|
40
|
+
/** For rotated refresh tokens — id of the parent credential this one replaced */
|
|
41
|
+
parentCredentialId?: string;
|
|
42
|
+
/** Timestamp of rotation; used by sliding rotation grace period */
|
|
43
|
+
rotatedAt?: number;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Result of issuing a credential or refreshing it.
|
|
47
|
+
*/
|
|
48
|
+
interface IssueResult {
|
|
49
|
+
accessToken: string;
|
|
50
|
+
refreshToken?: string;
|
|
51
|
+
accessExpiresAt: number;
|
|
52
|
+
refreshExpiresAt?: number;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Refresh token configuration.
|
|
56
|
+
*/
|
|
57
|
+
interface RefreshConfig {
|
|
58
|
+
/** Refresh token lifetime in milliseconds. */
|
|
59
|
+
ttl: number;
|
|
60
|
+
/**
|
|
61
|
+
* Rotation strategy. Defaults to 'sliding'.
|
|
62
|
+
*
|
|
63
|
+
* Note: 'sliding' grace-period replay tolerance only works against stateful
|
|
64
|
+
* stores (e.g. {@link CredentialStoreMemory}). Stateless stores (JWT,
|
|
65
|
+
* Encapsulated) cannot mutate an issued token in place; after the first
|
|
66
|
+
* rotation the old refresh becomes unusable, so 'sliding' degrades to
|
|
67
|
+
* 'always' semantics. Use 'always' explicitly for stateless deployments.
|
|
68
|
+
*/
|
|
69
|
+
rotation?: "none" | "always" | "sliding";
|
|
70
|
+
/** Grace period for sliding rotation, in milliseconds. Defaults to 30_000. */
|
|
71
|
+
rotationGraceMs?: number;
|
|
72
|
+
/** Theft detection hook — invoked when a previously-rotated refresh is reused. */
|
|
73
|
+
onRotationReuse?: (state: CredentialState) => void;
|
|
74
|
+
}
|
|
75
|
+
//#endregion
|
|
76
|
+
//#region src/stores/store.d.ts
|
|
77
|
+
/**
|
|
78
|
+
* Pluggable credential storage. Implementations: Memory, JWT, Encapsulated.
|
|
79
|
+
* Same shape works for stateful and stateless stores. Stateless stores treat
|
|
80
|
+
* the token as the state itself (sign/encrypt on persist, verify/decrypt on retrieve).
|
|
81
|
+
*
|
|
82
|
+
* `TClaims` is constrained to `extends object`, which TypeScript widens to
|
|
83
|
+
* include arrays, class instances, and `Function`. Pass a plain object type
|
|
84
|
+
* (e.g. `{ role: string; tenant: string }`); using arrays/classes will type-
|
|
85
|
+
* check but won't survive JSON-serialised stateless storage round-trips.
|
|
86
|
+
*/
|
|
87
|
+
interface CredentialStore<TClaims extends object = object> {
|
|
88
|
+
/** Persist state, return token. For stateless stores, token IS the state. */
|
|
89
|
+
persist(state: CredentialState<TClaims>, ttl?: number): Promise<string>;
|
|
90
|
+
/** Retrieve state from token. Returns null if expired/invalid/revoked. */
|
|
91
|
+
retrieve(token: string): Promise<CredentialState<TClaims> | null>;
|
|
92
|
+
/** Retrieve + invalidate (single-use). For stateless: needs denylist. */
|
|
93
|
+
consume(token: string): Promise<CredentialState<TClaims> | null>;
|
|
94
|
+
/**
|
|
95
|
+
* Update state. The returned token MAY differ from the input — stateful
|
|
96
|
+
* stores typically return the same token, while stateless stores re-issue
|
|
97
|
+
* a new token (denylisting the old). Callers must use the returned value.
|
|
98
|
+
*/
|
|
99
|
+
update(token: string, state: CredentialState<TClaims>): Promise<string>;
|
|
100
|
+
/** Revoke a single token. For stateless: requires denylist; otherwise throws. */
|
|
101
|
+
revoke(token: string): Promise<void>;
|
|
102
|
+
/**
|
|
103
|
+
* Revoke all credentials for a user. Returns count revoked.
|
|
104
|
+
* Stateless stores cannot enumerate individual tokens; they MAY implement
|
|
105
|
+
* the cascade via a per-user revocation epoch (see `CredentialStoreJwt`)
|
|
106
|
+
* and return a sentinel `1` to indicate "revocation took effect".
|
|
107
|
+
*/
|
|
108
|
+
revokeAllForUser(userId: string): Promise<number>;
|
|
109
|
+
/** List active credentials for a user (stateful only). */
|
|
110
|
+
listForUser?(userId: string): Promise<Array<CredentialState<TClaims> & {
|
|
111
|
+
token: string;
|
|
112
|
+
}>>;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Denylist for stateless revocation. Stores revoked-but-not-yet-expired token IDs.
|
|
116
|
+
* Auto-bounded by token TTLs.
|
|
117
|
+
*/
|
|
118
|
+
interface DenylistStore {
|
|
119
|
+
add(jti: string, expiresAt: number): Promise<void>;
|
|
120
|
+
has(jti: string): Promise<boolean>;
|
|
121
|
+
cleanup(): Promise<number>;
|
|
122
|
+
}
|
|
123
|
+
//#endregion
|
|
124
|
+
export { CredentialState as a, CredentialMetadata as i, DenylistStore as n, IssueResult as o, AuthContext as r, RefreshConfig as s, CredentialStore as t };
|