@c9up/warden 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +35 -0
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +81 -0
- package/scripts/copy-napi.mjs +67 -0
- package/src/AuthManager.ts +228 -0
- package/src/AuthRateLimiter.ts +105 -0
- package/src/Guard.ts +68 -0
- package/src/RefreshTokenStore.ts +67 -0
- package/src/TokenBlacklist.ts +58 -0
- package/src/WardenProvider.ts +84 -0
- package/src/bouncer/AuthorizationResponse.ts +38 -0
- package/src/bouncer/BasePolicy.ts +66 -0
- package/src/bouncer/Bouncer.ts +236 -0
- package/src/bouncer/PolicyAuthorizer.ts +125 -0
- package/src/bouncer/decorators.ts +44 -0
- package/src/bouncer/evaluate.ts +104 -0
- package/src/bouncer/policyContext.ts +53 -0
- package/src/bouncer/types.ts +67 -0
- package/src/config.ts +48 -0
- package/src/configure.ts +59 -0
- package/src/errors.ts +25 -0
- package/src/firstcontact/FirstContactManager.ts +54 -0
- package/src/firstcontact/drivers/GitHubDriver.ts +78 -0
- package/src/firstcontact/drivers/GoogleDriver.ts +81 -0
- package/src/firstcontact/types.ts +38 -0
- package/src/index.ts +77 -0
- package/src/middleware.ts +283 -0
- package/src/native.ts +53 -0
- package/src/rights/MemoryRightsStore.ts +92 -0
- package/src/rights/RightsResolver.ts +84 -0
- package/src/rights/types.ts +58 -0
- package/src/services/main.ts +40 -0
- package/src/standalone.ts +128 -0
- package/src/strategies/ApiKeyStrategy.ts +54 -0
- package/src/strategies/JwtStrategy.ts +217 -0
- package/src/strategies/SessionStrategy.ts +105 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standalone Warden factory — works without the Ream IoC container.
|
|
3
|
+
*
|
|
4
|
+
* Same pattern as `createBlackhole()`: construct the auth manager from a
|
|
5
|
+
* config object, return a typed handle that Express/Fastify/Hono can use.
|
|
6
|
+
*
|
|
7
|
+
* @example Express
|
|
8
|
+
* import { createWarden } from '@c9up/warden/standalone'
|
|
9
|
+
* const warden = createWarden({
|
|
10
|
+
* defaultStrategy: 'jwt',
|
|
11
|
+
* jwt: { secret: '...', findUser: ..., verifyCredentials: ... },
|
|
12
|
+
* })
|
|
13
|
+
* app.use(warden.expressMiddleware())
|
|
14
|
+
*
|
|
15
|
+
* @example Manual
|
|
16
|
+
* const result = await warden.verify(token)
|
|
17
|
+
* if (!result.authenticated) return res.status(401).json(result)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
AuthManager,
|
|
22
|
+
type AuthResult,
|
|
23
|
+
type UserPayload,
|
|
24
|
+
} from "./AuthManager.js";
|
|
25
|
+
import type { WardenConfig } from "./config.js";
|
|
26
|
+
import { JwtStrategy } from "./strategies/JwtStrategy.js";
|
|
27
|
+
|
|
28
|
+
export interface Warden {
|
|
29
|
+
/** Verify a bearer token. Returns auth result with user payload. */
|
|
30
|
+
verify(token: string, strategy?: string): Promise<AuthResult>;
|
|
31
|
+
/** Authenticate with credentials (email + password). */
|
|
32
|
+
authenticate(
|
|
33
|
+
credentials: Record<string, unknown>,
|
|
34
|
+
strategy?: string,
|
|
35
|
+
): Promise<AuthResult>;
|
|
36
|
+
/** Generate a JWT for a user (jwt strategy only). */
|
|
37
|
+
generateToken(user: UserPayload): Promise<string>;
|
|
38
|
+
/** The underlying AuthManager for advanced use. */
|
|
39
|
+
manager: AuthManager;
|
|
40
|
+
/** Express middleware — extracts Bearer token, verifies, attaches `req.auth`. */
|
|
41
|
+
expressMiddleware(): (
|
|
42
|
+
req: Record<string, unknown>,
|
|
43
|
+
res: Record<string, unknown>,
|
|
44
|
+
next: () => void,
|
|
45
|
+
) => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Create a standalone Warden instance — no Ream container needed.
|
|
50
|
+
*/
|
|
51
|
+
export function createWarden(config: WardenConfig): Warden {
|
|
52
|
+
const strategies: Record<string, JwtStrategy> = {};
|
|
53
|
+
|
|
54
|
+
if (config.jwt) {
|
|
55
|
+
strategies.jwt = new JwtStrategy(config.jwt);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const manager = new AuthManager({
|
|
59
|
+
defaultStrategy: config.defaultStrategy ?? "jwt",
|
|
60
|
+
strategies,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
verify: (token, strategy) => manager.verify(token, strategy),
|
|
65
|
+
authenticate: (credentials, strategy) =>
|
|
66
|
+
manager.authenticate(credentials, strategy),
|
|
67
|
+
|
|
68
|
+
async generateToken(user: UserPayload): Promise<string> {
|
|
69
|
+
const jwt = manager.getStrategy("jwt") as JwtStrategy;
|
|
70
|
+
return jwt.signToken(user);
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
manager,
|
|
74
|
+
|
|
75
|
+
expressMiddleware() {
|
|
76
|
+
return (req, res, next) => {
|
|
77
|
+
const headers = req.headers as Record<string, string> | undefined;
|
|
78
|
+
const authHeader = headers?.authorization ?? "";
|
|
79
|
+
const token =
|
|
80
|
+
typeof authHeader === "string" && authHeader.startsWith("Bearer ")
|
|
81
|
+
? authHeader.slice(7)
|
|
82
|
+
: "";
|
|
83
|
+
|
|
84
|
+
if (!token) {
|
|
85
|
+
(res as { status: (n: number) => { json: (d: unknown) => void } })
|
|
86
|
+
.status(401)
|
|
87
|
+
.json({
|
|
88
|
+
error: {
|
|
89
|
+
code: "UNAUTHORIZED",
|
|
90
|
+
message: "Missing authentication token",
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
manager
|
|
97
|
+
.verify(token)
|
|
98
|
+
.then((result) => {
|
|
99
|
+
if (!result.authenticated || !result.user) {
|
|
100
|
+
(res as { status: (n: number) => { json: (d: unknown) => void } })
|
|
101
|
+
.status(401)
|
|
102
|
+
.json({
|
|
103
|
+
error: {
|
|
104
|
+
code: "UNAUTHORIZED",
|
|
105
|
+
message: result.error ?? "Authentication failed",
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
// Attach auth result to the request object (Express convention).
|
|
111
|
+
req.auth = result;
|
|
112
|
+
req.user = result.user;
|
|
113
|
+
next();
|
|
114
|
+
})
|
|
115
|
+
.catch(() => {
|
|
116
|
+
(res as { status: (n: number) => { json: (d: unknown) => void } })
|
|
117
|
+
.status(500)
|
|
118
|
+
.json({
|
|
119
|
+
error: {
|
|
120
|
+
code: "AUTH_ERROR",
|
|
121
|
+
message: "Internal authentication error",
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
};
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API Key authentication strategy.
|
|
3
|
+
*
|
|
4
|
+
* Supports: Authorization: Bearer <key> or X-API-Key: <key>
|
|
5
|
+
*
|
|
6
|
+
* @implements MISS-8
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { AuthResult, AuthStrategy, UserPayload } from "../AuthManager.js";
|
|
10
|
+
|
|
11
|
+
export interface ApiKeyConfig {
|
|
12
|
+
headerName?: string;
|
|
13
|
+
findByKey: (
|
|
14
|
+
key: string,
|
|
15
|
+
) => Promise<{ user: UserPayload; scopes?: string[] } | null>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class ApiKeyStrategy implements AuthStrategy {
|
|
19
|
+
name = "api-key";
|
|
20
|
+
#config: ApiKeyConfig;
|
|
21
|
+
#headerName: string;
|
|
22
|
+
|
|
23
|
+
constructor(config: ApiKeyConfig) {
|
|
24
|
+
this.#config = config;
|
|
25
|
+
this.#headerName = config.headerName ?? "x-api-key";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The HTTP header name to extract the API key from. */
|
|
29
|
+
get headerName(): string {
|
|
30
|
+
return this.#headerName;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async authenticate(_credentials: {
|
|
34
|
+
email: string;
|
|
35
|
+
password: string;
|
|
36
|
+
}): Promise<AuthResult> {
|
|
37
|
+
throw new Error(
|
|
38
|
+
"ApiKeyStrategy does not support credential-based auth. Use verify() with the API key.",
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async verify(token: string): Promise<AuthResult> {
|
|
43
|
+
const result = await this.#config.findByKey(token);
|
|
44
|
+
if (!result) return { authenticated: false, error: "Invalid API key" };
|
|
45
|
+
|
|
46
|
+
const user: UserPayload = { ...result.user };
|
|
47
|
+
if (result.scopes && result.scopes.length > 0) {
|
|
48
|
+
// Merge scopes into permissions without mutating source object.
|
|
49
|
+
const merged = new Set([...(user.permissions ?? []), ...result.scopes]);
|
|
50
|
+
user.permissions = [...merged];
|
|
51
|
+
}
|
|
52
|
+
return { authenticated: true, user };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JwtStrategy — HMAC-SHA256 JWT authentication strategy.
|
|
3
|
+
* All signing and verification runs through the Rust warden-engine via NAPI.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
7
|
+
import type { AuthResult, AuthStrategy, UserPayload } from "../AuthManager.js";
|
|
8
|
+
import { nativeWarden } from "../native.js";
|
|
9
|
+
import type { TokenBlacklist } from "../TokenBlacklist.js";
|
|
10
|
+
|
|
11
|
+
interface JwtPayloadData {
|
|
12
|
+
sub: string;
|
|
13
|
+
roles?: string[];
|
|
14
|
+
permissions?: string[];
|
|
15
|
+
iat: number;
|
|
16
|
+
exp: number;
|
|
17
|
+
/** RFC 7519 JWT ID — populated at sign time so revocation can target a single token. */
|
|
18
|
+
jti: string;
|
|
19
|
+
[key: string]: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function sign(payload: JwtPayloadData, secret: string): string {
|
|
23
|
+
const rust = nativeWarden();
|
|
24
|
+
if (!rust) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
"[WARDEN_NAPI_REQUIRED] The Rust warden-engine binary is required. Build it with `cd packages/warden && pnpm build:napi`.",
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
return rust.jwtSign(JSON.stringify(payload), secret);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function verify(token: string, secret: string): JwtPayloadData | null {
|
|
33
|
+
const rust = nativeWarden();
|
|
34
|
+
if (!rust) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
"[WARDEN_NAPI_REQUIRED] The Rust warden-engine binary is required. Build it with `cd packages/warden && pnpm build:napi`.",
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const payloadJson = rust.jwtVerify(token, secret);
|
|
41
|
+
const payload = JSON.parse(payloadJson) as JwtPayloadData;
|
|
42
|
+
if (typeof payload.sub !== "string" || !payload.sub) return null;
|
|
43
|
+
return payload;
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface JwtStrategyConfig {
|
|
50
|
+
/**
|
|
51
|
+
* Active signing secret. New tokens are always signed with this key.
|
|
52
|
+
* For zero-downtime key rotation, also pass `previousSecrets` — verify
|
|
53
|
+
* tries each entry until one matches, then `revoke()` the rotated key
|
|
54
|
+
* by removing it from the config on the next deploy.
|
|
55
|
+
*/
|
|
56
|
+
secret: string;
|
|
57
|
+
/**
|
|
58
|
+
* Older secrets accepted at verify time but never used to sign new
|
|
59
|
+
* tokens. Empty / omitted by default. Order doesn't matter for
|
|
60
|
+
* correctness but ordering by recency keeps the common path fast.
|
|
61
|
+
*/
|
|
62
|
+
previousSecrets?: readonly string[];
|
|
63
|
+
expiresInSeconds?: number;
|
|
64
|
+
findUser: (id: string) => Promise<UserPayload | null>;
|
|
65
|
+
verifyCredentials: (
|
|
66
|
+
email: string,
|
|
67
|
+
password: string,
|
|
68
|
+
) => Promise<UserPayload | null>;
|
|
69
|
+
/**
|
|
70
|
+
* Optional blacklist for revocation. When supplied, `verify` rejects
|
|
71
|
+
* tokens whose `jti` is present, and `revoke(token)` adds the token's
|
|
72
|
+
* `jti` to the blacklist for the remainder of its lifetime. Without a
|
|
73
|
+
* blacklist, `revoke()` throws — the call would be a no-op silently.
|
|
74
|
+
*/
|
|
75
|
+
blacklist?: TokenBlacklist;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export class JwtStrategy implements AuthStrategy {
|
|
79
|
+
name = "jwt";
|
|
80
|
+
#secret: string;
|
|
81
|
+
/**
|
|
82
|
+
* Verify-only secrets ordered active-first. Mirrors `config.secret` at
|
|
83
|
+
* index 0 plus any `previousSecrets`. Built once at construction so
|
|
84
|
+
* `verify()` doesn't re-allocate per call.
|
|
85
|
+
*/
|
|
86
|
+
#verifySecrets: readonly string[];
|
|
87
|
+
#expiresIn: number;
|
|
88
|
+
#findUser: JwtStrategyConfig["findUser"];
|
|
89
|
+
#verifyCredentials: JwtStrategyConfig["verifyCredentials"];
|
|
90
|
+
#blacklist: TokenBlacklist | undefined;
|
|
91
|
+
|
|
92
|
+
constructor(config: JwtStrategyConfig) {
|
|
93
|
+
if (config.secret.length < 32) {
|
|
94
|
+
throw new Error("JWT secret must be at least 32 characters");
|
|
95
|
+
}
|
|
96
|
+
for (const prev of config.previousSecrets ?? []) {
|
|
97
|
+
if (prev.length < 32) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
"JWT previousSecrets entries must be at least 32 characters",
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
this.#secret = config.secret;
|
|
104
|
+
this.#verifySecrets = [config.secret, ...(config.previousSecrets ?? [])];
|
|
105
|
+
this.#expiresIn = config.expiresInSeconds ?? 3600;
|
|
106
|
+
this.#findUser = config.findUser;
|
|
107
|
+
this.#verifyCredentials = config.verifyCredentials;
|
|
108
|
+
this.#blacklist = config.blacklist;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Try each accepted secret in order. First successful verify wins.
|
|
113
|
+
* Returns `null` when no secret accepts the token — same semantics as
|
|
114
|
+
* the single-secret path so callers see "invalid or expired token".
|
|
115
|
+
*/
|
|
116
|
+
#verifyWithRotation(token: string): JwtPayloadData | null {
|
|
117
|
+
for (const secret of this.#verifySecrets) {
|
|
118
|
+
const payload = verify(token, secret);
|
|
119
|
+
if (payload) return payload;
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async authenticate(
|
|
125
|
+
credentials: Record<string, unknown>,
|
|
126
|
+
): Promise<AuthResult> {
|
|
127
|
+
const email =
|
|
128
|
+
typeof credentials.email === "string" ? credentials.email : null;
|
|
129
|
+
const password =
|
|
130
|
+
typeof credentials.password === "string" ? credentials.password : null;
|
|
131
|
+
if (!email || !password) {
|
|
132
|
+
return { authenticated: false, error: "Email and password are required" };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const user = await this.#verifyCredentials(email, password);
|
|
136
|
+
if (!user) {
|
|
137
|
+
return { authenticated: false, error: "Invalid credentials" };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const token = this.signToken(user);
|
|
141
|
+
return { authenticated: true, user: { ...user, token } };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async verify(token: string): Promise<AuthResult> {
|
|
145
|
+
const payload = this.#verifyWithRotation(token);
|
|
146
|
+
if (!payload) {
|
|
147
|
+
return { authenticated: false, error: "Invalid or expired token" };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Blacklist check runs BEFORE findUser so a revoked token never
|
|
151
|
+
// triggers a user lookup (cheaper, and avoids leaking user existence
|
|
152
|
+
// via timing differences between revoked and unknown-user paths).
|
|
153
|
+
if (this.#blacklist) {
|
|
154
|
+
// A token with no `jti` cannot be checked against the blacklist
|
|
155
|
+
// (`isRevoked(undefined)` always returns false), so it could
|
|
156
|
+
// never be revoked. When revocation is configured we must NOT
|
|
157
|
+
// trust such a token — reject it rather than let an
|
|
158
|
+
// unrevocable token through. (signToken always sets a jti;
|
|
159
|
+
// this guards legacy / externally-signed tokens.)
|
|
160
|
+
if (typeof payload.jti !== "string" || payload.jti.length === 0) {
|
|
161
|
+
return { authenticated: false, error: "Token missing jti claim" };
|
|
162
|
+
}
|
|
163
|
+
if (await this.#blacklist.isRevoked(payload.jti)) {
|
|
164
|
+
return { authenticated: false, error: "Token revoked" };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const user = await this.#findUser(payload.sub);
|
|
169
|
+
if (!user) {
|
|
170
|
+
return { authenticated: false, error: "User not found" };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { authenticated: true, user };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
signToken(user: UserPayload): string {
|
|
177
|
+
const now = Math.floor(Date.now() / 1000);
|
|
178
|
+
return sign(
|
|
179
|
+
{
|
|
180
|
+
sub: user.id,
|
|
181
|
+
roles: user.roles,
|
|
182
|
+
permissions: user.permissions,
|
|
183
|
+
iat: now,
|
|
184
|
+
exp: now + this.#expiresIn,
|
|
185
|
+
jti: randomUUID(),
|
|
186
|
+
},
|
|
187
|
+
this.#secret,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Revoke a token until its `exp` claim. Requires the strategy to be
|
|
193
|
+
* constructed with a `blacklist` driver — otherwise throws so that a
|
|
194
|
+
* caller assuming revocation does not silently succeed against
|
|
195
|
+
* a no-op implementation.
|
|
196
|
+
*
|
|
197
|
+
* Returns `true` when the token was added to the blacklist, `false` when
|
|
198
|
+
* the token is already expired (revocation is unnecessary) or unparseable.
|
|
199
|
+
*/
|
|
200
|
+
async revoke(token: string): Promise<boolean> {
|
|
201
|
+
if (!this.#blacklist) {
|
|
202
|
+
throw new Error(
|
|
203
|
+
"JwtStrategy.revoke() requires a `blacklist` driver. Pass `{ blacklist: new TokenBlacklist(driver) }` to the constructor.",
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
const payload = this.#verifyWithRotation(token);
|
|
207
|
+
if (!payload) return false;
|
|
208
|
+
const expMs = payload.exp * 1000;
|
|
209
|
+
if (expMs <= Date.now()) return false;
|
|
210
|
+
await this.#blacklist.revoke(payload.jti, expMs);
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function generateJwtSecret(): string {
|
|
216
|
+
return randomBytes(48).toString("base64url");
|
|
217
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-based authentication strategy.
|
|
3
|
+
*
|
|
4
|
+
* AdonisJS session guard pattern:
|
|
5
|
+
* login stores userId in session, subsequent requests check session.
|
|
6
|
+
*
|
|
7
|
+
* @implements MISS-7
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { AuthResult, AuthStrategy, UserPayload } from "../AuthManager.js";
|
|
11
|
+
import { WardenError } from "../errors.js";
|
|
12
|
+
|
|
13
|
+
export interface SessionStore {
|
|
14
|
+
get(key: string): unknown;
|
|
15
|
+
set(key: string, value: unknown): void;
|
|
16
|
+
forget(key: string): void;
|
|
17
|
+
/**
|
|
18
|
+
* Rotate the session id while preserving the data. REQUIRED to mitigate
|
|
19
|
+
* session fixation (CWE-384) at login. Ream's `Session.regenerate()`
|
|
20
|
+
* implements this; adapters around other session stores MUST too. The
|
|
21
|
+
* default ream → warden plumbing exposes the `Session` instance
|
|
22
|
+
* directly so this is wired automatically.
|
|
23
|
+
*/
|
|
24
|
+
regenerate(): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SessionStrategyConfig {
|
|
28
|
+
sessionKey?: string;
|
|
29
|
+
findUser: (id: string | number) => Promise<UserPayload | null>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class SessionStrategy implements AuthStrategy {
|
|
33
|
+
name = "session";
|
|
34
|
+
#config: SessionStrategyConfig;
|
|
35
|
+
#sessionKey: string;
|
|
36
|
+
|
|
37
|
+
constructor(config: SessionStrategyConfig) {
|
|
38
|
+
this.#config = config;
|
|
39
|
+
this.#sessionKey = config.sessionKey ?? "auth_user_id";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Authenticate via email/password — stores user ID in session. */
|
|
43
|
+
async authenticate(
|
|
44
|
+
_credentials: { email: string; password: string },
|
|
45
|
+
_context?: { session?: SessionStore },
|
|
46
|
+
): Promise<AuthResult> {
|
|
47
|
+
// Session strategy doesn't handle password verification — that's done
|
|
48
|
+
// by the caller. We throw a typed sentinel WardenError so AuthManager's
|
|
49
|
+
// try/catch (which swallows generic errors into a soft AuthResult)
|
|
50
|
+
// re-throws this one — the design boundary stays unmissable even when
|
|
51
|
+
// a caller routes through `authManager.authenticate()` instead of
|
|
52
|
+
// `strategy.authenticate()` directly.
|
|
53
|
+
throw new WardenError(
|
|
54
|
+
"USE_LOGIN",
|
|
55
|
+
"SessionStrategy.authenticate() requires login() instead. Use authManager.login(user, session).",
|
|
56
|
+
{
|
|
57
|
+
hint: "Verify the password yourself (e.g. via @c9up/sigil Hash.verify), then call SessionStrategy.login(user, session).",
|
|
58
|
+
},
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Verify session — check if user ID is stored in session. */
|
|
63
|
+
async verify(_token: string): Promise<AuthResult> {
|
|
64
|
+
return {
|
|
65
|
+
authenticated: false,
|
|
66
|
+
error:
|
|
67
|
+
"SessionStrategy.verify() requires context. Use verifyWithContext().",
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Verify session with context — check if user ID is stored in session. */
|
|
72
|
+
async verifyWithContext(
|
|
73
|
+
_token: string,
|
|
74
|
+
context?: { session?: SessionStore },
|
|
75
|
+
): Promise<AuthResult> {
|
|
76
|
+
if (!context?.session) return { authenticated: false, error: "No session" };
|
|
77
|
+
const userId = context.session.get(this.#sessionKey);
|
|
78
|
+
if (!userId) return { authenticated: false, error: "No session user" };
|
|
79
|
+
if (typeof userId !== "string" && typeof userId !== "number")
|
|
80
|
+
return { authenticated: false, error: "Invalid session data" };
|
|
81
|
+
const user = await this.#config.findUser(userId);
|
|
82
|
+
if (!user) return { authenticated: false, error: "User not found" };
|
|
83
|
+
return { authenticated: true, user };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Login a user — store their ID in the session.
|
|
88
|
+
*
|
|
89
|
+
* Rotates the session id BEFORE writing the authenticated user id, so an
|
|
90
|
+
* attacker who fed the victim a pre-login session cookie (classic
|
|
91
|
+
* session-fixation attack, CWE-384) ends up holding a now-discarded id
|
|
92
|
+
* while the victim continues under a fresh one. The driver-side cookie
|
|
93
|
+
* migration is handled by Ream's `SessionMiddleware` on the response
|
|
94
|
+
* path — see `wasRegenerated()` there.
|
|
95
|
+
*/
|
|
96
|
+
async login(user: UserPayload, session: SessionStore): Promise<void> {
|
|
97
|
+
session.regenerate();
|
|
98
|
+
session.set(this.#sessionKey, user.id);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Logout — remove user ID from session. */
|
|
102
|
+
async logout(session: SessionStore): Promise<void> {
|
|
103
|
+
session.forget(this.#sessionKey);
|
|
104
|
+
}
|
|
105
|
+
}
|