@bhooai/nexus-auth 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/README.md +31 -0
- package/package.json +22 -0
- package/src/auth.ts +116 -0
- package/src/bcryptjs.d.ts +10 -0
- package/src/cors.ts +91 -0
- package/src/csrf.ts +158 -0
- package/src/headers.ts +55 -0
- package/src/index.ts +11 -0
- package/src/jwt.ts +65 -0
- package/src/middleware.ts +94 -0
- package/src/oauth.ts +199 -0
- package/src/password.ts +33 -0
- package/src/rateLimit.ts +59 -0
- package/src/rbac.ts +94 -0
- package/src/session.ts +91 -0
- package/tests/auth.test.ts +230 -0
- package/tests/security.test.ts +133 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +9 -0
package/README.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# @bhooai/nexus-auth
|
|
2
|
+
|
|
3
|
+
One cohesive security + identity unit: CORS, CSRF, security headers, rate
|
|
4
|
+
limiting, password hashing, JWT auth, sessions, RBAC, and Google + Facebook OAuth2.
|
|
5
|
+
|
|
6
|
+
## Exports
|
|
7
|
+
|
|
8
|
+
- **CORS** — `cors({ origin, credentials })` with preflight short-circuit and `Vary: Origin`.
|
|
9
|
+
- **CSRF** — `csrf({ trustedOrigins })` double-submit token (HttpOnly `nexus_csrf`
|
|
10
|
+
cookie + `x-csrf-token` header); `issueCsrfToken(ctx, { trustedOrigins })`. Safe
|
|
11
|
+
methods issue a fresh token; unsafe methods run `checkOrigin` + double-submit.
|
|
12
|
+
Applies to **all** unsafe methods (incl. `POST /graphql` and `/payments`) and to
|
|
13
|
+
the **WS upgrade**.
|
|
14
|
+
- **headers** — `securityHeaders()` (helmet-equivalent).
|
|
15
|
+
- **rateLimit** — `rateLimit({ windowMs, max })` (in-memory; Redis backend in `nexus-cache`).
|
|
16
|
+
- **jwt / session / password** — `AuthService`, `MemorySessionStore`,
|
|
17
|
+
`hashPassword`/`verifyPassword`, access + refresh token rotation.
|
|
18
|
+
- **rbac** — role checks.
|
|
19
|
+
- **oauth** — `buildGoogleAuthUrl` (with PKCE), `exchangeGoogleCode`,
|
|
20
|
+
`fetchGoogleProfile`, and the Facebook equivalents.
|
|
21
|
+
- **middleware** — `authToken(service, { cookieName, allowCookie, required })`,
|
|
22
|
+
`requireAuth()`, `setAuthCookies`, `clearAuthCookies`.
|
|
23
|
+
|
|
24
|
+
> `authToken`'s first argument is the `AuthService` instance; options are the 2nd.
|
|
25
|
+
|
|
26
|
+
## CSRF note
|
|
27
|
+
|
|
28
|
+
With a non-empty `trustedOrigins`, every unsafe request **must** carry an `Origin`
|
|
29
|
+
header that exactly matches a trusted origin (including the port) **and** a
|
|
30
|
+
matching `x-csrf-token`. API clients that don't send `Origin` (e.g. Playwright's
|
|
31
|
+
`APIRequestContext`) must add it explicitly — see the e2e/integration tests.
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bhooai/nexus-auth",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"publishConfig": { "access": "public" },
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "tsc -p tsconfig.json",
|
|
10
|
+
"test": "vitest run"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@bhooai/nexus-core": "^0.1.0",
|
|
14
|
+
"bcryptjs": "^2.4.3",
|
|
15
|
+
"jose": "^5.9.6"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/node": "^22.5.0",
|
|
19
|
+
"typescript": "^5.6.2",
|
|
20
|
+
"vitest": "^2.1.1"
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { signAccessToken, verifyToken, type JwtOptions, type TokenPayload } from './jwt.js';
|
|
3
|
+
import type { SessionStore, Session } from './session.js';
|
|
4
|
+
import { AuthenticationError } from '../../nexus-core/src/index.js';
|
|
5
|
+
|
|
6
|
+
export interface TokenPair {
|
|
7
|
+
accessToken: string;
|
|
8
|
+
refreshToken: string;
|
|
9
|
+
sessionId: string;
|
|
10
|
+
/** Refresh-token expiry (ms epoch). */
|
|
11
|
+
refreshExpiresAt: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface LoginInput {
|
|
15
|
+
userId: string;
|
|
16
|
+
roles: string[];
|
|
17
|
+
meta?: Record<string, unknown>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* High-level auth orchestrator. The app verifies credentials (via
|
|
22
|
+
* `verifyPassword` + its user store) and calls `login()` to mint a session and
|
|
23
|
+
* token pair. Refresh tokens are rotated on every refresh and bound to a
|
|
24
|
+
* session family, so reuse of a previously-rotated token revokes the whole
|
|
25
|
+
* family (refresh-token theft detection).
|
|
26
|
+
*/
|
|
27
|
+
export class AuthService {
|
|
28
|
+
constructor(
|
|
29
|
+
private jwt: JwtOptions,
|
|
30
|
+
private sessions: SessionStore,
|
|
31
|
+
) {}
|
|
32
|
+
|
|
33
|
+
/** Create a session and return an access + refresh token pair. */
|
|
34
|
+
async login(input: LoginInput): Promise<TokenPair> {
|
|
35
|
+
const session = await this.sessions.create(input.userId, input.roles, input.meta);
|
|
36
|
+
return this.mintPair(session);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Verify a refresh token, rotate it (new jti), and return a fresh pair.
|
|
41
|
+
* If the presented refresh token's jti no longer matches the session's
|
|
42
|
+
* current jti, the token has been reused after rotation → revoke the family.
|
|
43
|
+
*/
|
|
44
|
+
async refresh(refreshToken: string): Promise<TokenPair> {
|
|
45
|
+
let payload: TokenPayload;
|
|
46
|
+
try {
|
|
47
|
+
payload = await verifyToken(refreshToken, this.jwt);
|
|
48
|
+
} catch {
|
|
49
|
+
throw new AuthenticationError('Invalid refresh token');
|
|
50
|
+
}
|
|
51
|
+
if (payload.kind !== 'refresh' || !payload.sid) {
|
|
52
|
+
throw new AuthenticationError('Not a refresh token');
|
|
53
|
+
}
|
|
54
|
+
const session = await this.sessions.get(payload.sid);
|
|
55
|
+
if (!session) throw new AuthenticationError('Session expired');
|
|
56
|
+
|
|
57
|
+
// Reuse detection: the jti on the session must match the presented token.
|
|
58
|
+
if (session.currentJti && payload.jti && session.currentJti !== payload.jti) {
|
|
59
|
+
await this.sessions.destroyFamily(session.familyId);
|
|
60
|
+
throw new AuthenticationError('Refresh token reuse detected; session revoked');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return this.mintPair(session);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** End a single session (logout). */
|
|
67
|
+
async logout(sessionId: string): Promise<void> {
|
|
68
|
+
await this.sessions.destroy(sessionId);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** End every session for a user (logout everywhere). */
|
|
72
|
+
async logoutAll(userId: string): Promise<void> {
|
|
73
|
+
await this.sessions.destroyAllForUser(userId);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Verify an access token and return its payload (for the auth middleware). */
|
|
77
|
+
async verifyAccessToken(accessToken: string): Promise<TokenPayload> {
|
|
78
|
+
try {
|
|
79
|
+
const payload = await verifyToken(accessToken, this.jwt);
|
|
80
|
+
if (payload.kind && payload.kind !== 'access') {
|
|
81
|
+
throw new AuthenticationError('Not an access token');
|
|
82
|
+
}
|
|
83
|
+
return payload;
|
|
84
|
+
} catch (e) {
|
|
85
|
+
if (e instanceof AuthenticationError) throw e;
|
|
86
|
+
throw new AuthenticationError('Invalid access token');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private async mintPair(session: Session): Promise<TokenPair> {
|
|
91
|
+
const jti = randomBytes(12).toString('base64url');
|
|
92
|
+
// Record the issued refresh jti on the session for reuse detection.
|
|
93
|
+
await this.sessions.update(session.id, { currentJti: jti });
|
|
94
|
+
const access = await signAccessToken(session.userId, session.roles, this.jwt, session.id);
|
|
95
|
+
const refresh = await this.signRefreshWithJti(session, jti);
|
|
96
|
+
const refreshExpiresAt = Date.now() + (this.jwt.refreshTtl ?? 60 * 60 * 24 * 7) * 1000;
|
|
97
|
+
return { accessToken: access, refreshToken: refresh, sessionId: session.id, refreshExpiresAt };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private async signRefreshWithJti(session: Session, jti: string): Promise<string> {
|
|
101
|
+
// Re-use the jose SignJWT path by importing here to inject jti.
|
|
102
|
+
const { SignJWT } = await import('jose');
|
|
103
|
+
const ttl = this.jwt.refreshTtl ?? 60 * 60 * 24 * 7;
|
|
104
|
+
const secret =
|
|
105
|
+
typeof this.jwt.secret === 'string' ? new TextEncoder().encode(this.jwt.secret) : this.jwt.secret;
|
|
106
|
+
const builder = new SignJWT({ kind: 'refresh', roles: session.roles, sid: session.id })
|
|
107
|
+
.setProtectedHeader({ alg: this.jwt.algorithm ?? 'HS256' })
|
|
108
|
+
.setSubject(session.userId)
|
|
109
|
+
.setJti(jti)
|
|
110
|
+
.setIssuedAt()
|
|
111
|
+
.setExpirationTime(`${ttl}s`);
|
|
112
|
+
if (this.jwt.issuer) builder.setIssuer(this.jwt.issuer);
|
|
113
|
+
if (this.jwt.audience) builder.setAudience(this.jwt.audience);
|
|
114
|
+
return builder.sign(secret);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
declare module 'bcryptjs' {
|
|
2
|
+
interface Bcrypt {
|
|
3
|
+
genSalt(rounds: number): Promise<string>;
|
|
4
|
+
hash(plaintext: string, salt: string): Promise<string>;
|
|
5
|
+
compare(plaintext: string, hash: string): Promise<boolean>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const bcrypt: Bcrypt;
|
|
9
|
+
export default bcrypt;
|
|
10
|
+
}
|
package/src/cors.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { Middleware } from '../../nexus-core/src/http/index.js';
|
|
2
|
+
|
|
3
|
+
export interface CorsOptions {
|
|
4
|
+
/** Allowed origin(s): a string, an array, or true to reflect any origin. */
|
|
5
|
+
origin?: string | string[] | boolean;
|
|
6
|
+
/** Allowed methods. */
|
|
7
|
+
methods?: string[];
|
|
8
|
+
/** Allowed request headers. */
|
|
9
|
+
allowedHeaders?: string[];
|
|
10
|
+
/** Headers exposed to the browser. */
|
|
11
|
+
exposedHeaders?: string[];
|
|
12
|
+
/** Allow cookies / credentials. When true, origin cannot be "*". */
|
|
13
|
+
credentials?: boolean;
|
|
14
|
+
/** Preflight cache max age in seconds. */
|
|
15
|
+
maxAge?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const DEFAULT_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Inbuilt CORS middleware. Owns the OPTIONS preflight path entirely, sets
|
|
22
|
+
* `Vary: Origin` for correct cache behavior, and enforces the credentialed
|
|
23
|
+
* rule (no wildcard origin when credentials are enabled).
|
|
24
|
+
*/
|
|
25
|
+
export function cors(options: CorsOptions = {}): Middleware {
|
|
26
|
+
const opts = {
|
|
27
|
+
origin: options.origin ?? true,
|
|
28
|
+
methods: options.methods ?? DEFAULT_METHODS,
|
|
29
|
+
allowedHeaders: options.allowedHeaders,
|
|
30
|
+
exposedHeaders: options.exposedHeaders,
|
|
31
|
+
credentials: options.credentials ?? false,
|
|
32
|
+
maxAge: options.maxAge ?? 600,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
return async (ctx, next) => {
|
|
36
|
+
const reqOrigin = (ctx.headers['origin'] as string) ?? '';
|
|
37
|
+
const allowedOrigin = resolveOrigin(opts.origin, reqOrigin, opts.credentials);
|
|
38
|
+
|
|
39
|
+
// Always vary by origin so caches don't leak the wrong CORS headers.
|
|
40
|
+
appendVary(ctx, 'Origin');
|
|
41
|
+
|
|
42
|
+
if (allowedOrigin) {
|
|
43
|
+
ctx.setHeader('access-control-allow-origin', allowedOrigin);
|
|
44
|
+
if (opts.credentials) ctx.setHeader('access-control-allow-credentials', 'true');
|
|
45
|
+
if (opts.exposedHeaders?.length) {
|
|
46
|
+
ctx.setHeader('access-control-expose-headers', opts.exposedHeaders.join(', '));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (ctx.method === 'OPTIONS' && ctx.headers['access-control-request-method']) {
|
|
51
|
+
// Preflight: short-circuit
|
|
52
|
+
ctx.setHeader('access-control-allow-methods', opts.methods.join(', '));
|
|
53
|
+
const reqHeaders = (ctx.headers['access-control-request-headers'] as string) ?? '';
|
|
54
|
+
ctx.setHeader('access-control-allow-headers', (opts.allowedHeaders ?? reqHeaders) || '*');
|
|
55
|
+
ctx.setHeader('access-control-max-age', String(opts.maxAge));
|
|
56
|
+
ctx.status(204);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
await next();
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function resolveOrigin(
|
|
65
|
+
config: string | string[] | boolean,
|
|
66
|
+
requestOrigin: string,
|
|
67
|
+
credentials: boolean,
|
|
68
|
+
): string | undefined {
|
|
69
|
+
if (config === true) {
|
|
70
|
+
// Reflect the request origin (works with credentials); no "*" when credentialed.
|
|
71
|
+
return requestOrigin || (credentials ? undefined : '*');
|
|
72
|
+
}
|
|
73
|
+
if (typeof config === 'string') {
|
|
74
|
+
if (config === '*') return credentials ? requestOrigin || undefined : '*';
|
|
75
|
+
return config === requestOrigin ? config : undefined;
|
|
76
|
+
}
|
|
77
|
+
if (Array.isArray(config)) {
|
|
78
|
+
return config.includes(requestOrigin) ? requestOrigin : undefined;
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function appendVary(ctx: Parameters<Middleware>[0], value: string): void {
|
|
84
|
+
const existing = ctx.res.getHeader('vary');
|
|
85
|
+
if (existing === undefined) {
|
|
86
|
+
ctx.setHeader('vary', value);
|
|
87
|
+
} else {
|
|
88
|
+
const merged = `${existing}, ${value}`;
|
|
89
|
+
ctx.setHeader('vary', merged);
|
|
90
|
+
}
|
|
91
|
+
}
|
package/src/csrf.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import type { Middleware, RequestContext } from '../../nexus-core/src/http/index.js';
|
|
3
|
+
import { AuthenticationError } from '../../nexus-core/src/index.js';
|
|
4
|
+
|
|
5
|
+
export interface CsrfOptions {
|
|
6
|
+
/** Cookie name holding the double-submit token. */
|
|
7
|
+
cookieName?: string;
|
|
8
|
+
/** Header / body field name carrying the token on unsafe requests. */
|
|
9
|
+
tokenName?: string;
|
|
10
|
+
/** Trusted origin hosts (for the origin/referer check). If omitted, origin check is skipped. */
|
|
11
|
+
trustedOrigins?: string[];
|
|
12
|
+
/** Methods exempt from CSRF (safe methods). */
|
|
13
|
+
safeMethods?: string[];
|
|
14
|
+
/** Cookie attributes. */
|
|
15
|
+
cookie?: { path?: string; secure?: boolean; sameSite?: 'strict' | 'lax' | 'none'; domain?: string };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const SAFE = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Inbuilt CSRF protection using the double-submit cookie pattern plus an
|
|
22
|
+
* origin/referer check on unsafe requests. Safe methods are exempt.
|
|
23
|
+
*
|
|
24
|
+
* On a safe request, a token cookie is set (rotated each request) and exposed
|
|
25
|
+
* via `ctx.state.csrfToken` so handlers can return it to clients. On unsafe
|
|
26
|
+
* requests, the header token must equal the cookie token.
|
|
27
|
+
*/
|
|
28
|
+
export function csrf(options: CsrfOptions = {}): Middleware {
|
|
29
|
+
const cookieName = options.cookieName ?? 'nexus_csrf';
|
|
30
|
+
const tokenName = options.tokenName ?? 'x-csrf-token';
|
|
31
|
+
const trusted = options.trustedOrigins ?? [];
|
|
32
|
+
const safeMethods = new Set(options.safeMethods ?? [...SAFE]);
|
|
33
|
+
const cookieOpts = options.cookie ?? { path: '/', sameSite: 'lax', secure: false };
|
|
34
|
+
|
|
35
|
+
return async (ctx, next) => {
|
|
36
|
+
if (safeMethods.has(ctx.method)) {
|
|
37
|
+
// Reuse an existing cookie token so concurrent safe requests (e.g. the
|
|
38
|
+
// 5-second cluster overview poll) don't rotate the token out from under
|
|
39
|
+
// a pending unsafe request — that race produced "Unauthorized" on POSTs.
|
|
40
|
+
const existing = readCookie(ctx, cookieName);
|
|
41
|
+
if (existing) {
|
|
42
|
+
ctx.state.csrfToken = existing;
|
|
43
|
+
} else {
|
|
44
|
+
ctx.state.csrfToken = issueToken(ctx, cookieName, cookieOpts);
|
|
45
|
+
}
|
|
46
|
+
await next();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Unsafe method: validate origin/referer first.
|
|
51
|
+
checkOrigin(ctx, trusted);
|
|
52
|
+
|
|
53
|
+
// Then double-submit: header token must match cookie token.
|
|
54
|
+
const cookieToken = readCookie(ctx, cookieName);
|
|
55
|
+
const sentToken =
|
|
56
|
+
(ctx.headers[tokenName] as string) ??
|
|
57
|
+
(ctx.body && typeof ctx.body === 'object' ? (ctx.body as Record<string, unknown>)[tokenName] as string : undefined);
|
|
58
|
+
|
|
59
|
+
if (!cookieToken || !sentToken || !timingSafeEqual(cookieToken, sentToken)) {
|
|
60
|
+
throw new AuthenticationError('Invalid CSRF token');
|
|
61
|
+
}
|
|
62
|
+
await next();
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Helper for handlers/endpoints to mint a token (e.g. a /csrf-token route). */
|
|
67
|
+
export function issueCsrfToken(ctx: RequestContext, options: CsrfOptions = {}): string {
|
|
68
|
+
return issueToken(ctx, options.cookieName ?? 'nexus_csrf', options.cookie ?? { path: '/', sameSite: 'lax', secure: false });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** CSRF + origin check for the WebSocket upgrade request (no CORS preflight). */
|
|
72
|
+
export function checkWsUpgrade(headers: Record<string, string | string[] | undefined>, options: CsrfOptions = {}): void {
|
|
73
|
+
const trusted = options.trustedOrigins ?? [];
|
|
74
|
+
if (trusted.length) {
|
|
75
|
+
const origin = (headers['origin'] as string) ?? '';
|
|
76
|
+
if (!origin || !isTrustedOrigin(origin, trusted)) {
|
|
77
|
+
throw new AuthenticationError('Untrusted WebSocket origin');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const cookieToken = readCookieFromHeader((headers['cookie'] as string) ?? '', options.cookieName ?? 'nexus_csrf');
|
|
81
|
+
const queryToken = (headers['sec-websocket-protocol'] as string)?.split(',').map((s) => s.trim())[0];
|
|
82
|
+
if (!cookieToken || !queryToken || !timingSafeEqual(cookieToken, queryToken)) {
|
|
83
|
+
throw new AuthenticationError('Invalid WebSocket CSRF token');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function issueToken(
|
|
88
|
+
ctx: RequestContext,
|
|
89
|
+
cookieName: string,
|
|
90
|
+
opts: NonNullable<CsrfOptions['cookie']>,
|
|
91
|
+
): string {
|
|
92
|
+
const token = randomBytes(24).toString('base64url');
|
|
93
|
+
const parts = [
|
|
94
|
+
`${cookieName}=${token}`,
|
|
95
|
+
`Path=${opts.path ?? '/'}`,
|
|
96
|
+
`SameSite=${opts.sameSite ?? 'lax'}`,
|
|
97
|
+
opts.secure ? 'Secure' : '',
|
|
98
|
+
opts.domain ? `Domain=${opts.domain}` : '',
|
|
99
|
+
'HttpOnly',
|
|
100
|
+
].filter(Boolean);
|
|
101
|
+
ctx.setHeader('set-cookie', parts.join('; '));
|
|
102
|
+
return token;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function readCookie(ctx: RequestContext, name: string): string | undefined {
|
|
106
|
+
return readCookieFromHeader((ctx.headers['cookie'] as string) ?? '', name);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function readCookieFromHeader(header: string, name: string): string | undefined {
|
|
110
|
+
for (const part of header.split(';')) {
|
|
111
|
+
const eq = part.indexOf('=');
|
|
112
|
+
const k = part.slice(0, eq).trim();
|
|
113
|
+
if (k === name) return part.slice(eq + 1).trim();
|
|
114
|
+
}
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function checkOrigin(ctx: RequestContext, trusted: string[]): void {
|
|
119
|
+
if (trusted.length === 0) return;
|
|
120
|
+
const origin = (ctx.headers['origin'] as string) ?? (ctx.headers['referer'] as string) ?? '';
|
|
121
|
+
if (!origin || !isTrustedOrigin(origin, trusted)) {
|
|
122
|
+
throw new AuthenticationError('Untrusted request origin');
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function isTrustedOrigin(origin: string, trusted: string[]): boolean {
|
|
127
|
+
let hostname: string;
|
|
128
|
+
let host: string;
|
|
129
|
+
try {
|
|
130
|
+
const u = new URL(origin);
|
|
131
|
+
hostname = u.hostname;
|
|
132
|
+
host = u.host;
|
|
133
|
+
} catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
// Loopback origins (localhost/127.0.0.1) are safe from cross-site forgery
|
|
137
|
+
// regardless of port — Docker / port-forwarding commonly remaps host ports
|
|
138
|
+
// (e.g. container :3001 published on the host as :3011), so a strict
|
|
139
|
+
// host:port match would wrongly reject the admin/frontend dev servers.
|
|
140
|
+
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') {
|
|
141
|
+
return trusted.some((t) => {
|
|
142
|
+
try {
|
|
143
|
+
const tu = new URL(t);
|
|
144
|
+
return tu.hostname === 'localhost' || tu.hostname === '127.0.0.1' || tu.hostname === '::1';
|
|
145
|
+
} catch {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return trusted.some((t) => t === host || t === origin || origin.startsWith(t));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function timingSafeEqual(a: string, b: string): boolean {
|
|
154
|
+
const ab = Buffer.from(a);
|
|
155
|
+
const bb = Buffer.from(b);
|
|
156
|
+
if (ab.length !== bb.length) return false;
|
|
157
|
+
return ab.equals(bb);
|
|
158
|
+
}
|
package/src/headers.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Middleware } from '../../nexus-core/src/http/index.js';
|
|
2
|
+
|
|
3
|
+
export interface SecurityHeadersOptions {
|
|
4
|
+
/** HSTS max age in seconds (0 to disable). */
|
|
5
|
+
hstsMaxAge?: number;
|
|
6
|
+
/** Include subdomains in HSTS. */
|
|
7
|
+
hstsIncludeSubdomains?: boolean;
|
|
8
|
+
/** Enable HSTS preload. */
|
|
9
|
+
hstsPreload?: boolean;
|
|
10
|
+
/** Content-Security-Policy directive string. */
|
|
11
|
+
csp?: string;
|
|
12
|
+
/** Referrer-Policy. */
|
|
13
|
+
referrerPolicy?: string;
|
|
14
|
+
/** X-Content-Type-Options. */
|
|
15
|
+
noSniff?: boolean;
|
|
16
|
+
/** X-Frame-Options (DENY/SAMEORIGIN) — superseded by CSP frame-ancestors when present. */
|
|
17
|
+
frameOptions?: 'DENY' | 'SAMEORIGIN' | false;
|
|
18
|
+
/** Cross-Origin-Opener-Policy. */
|
|
19
|
+
coop?: string;
|
|
20
|
+
/** Cross-Origin-Resource-Policy. */
|
|
21
|
+
corp?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Helmet-equivalent security headers, applied to every response. */
|
|
25
|
+
export function securityHeaders(options: SecurityHeadersOptions = {}): Middleware {
|
|
26
|
+
const opts = {
|
|
27
|
+
hstsMaxAge: options.hstsMaxAge ?? 15552000, // 180 days
|
|
28
|
+
hstsIncludeSubdomains: options.hstsIncludeSubdomains ?? true,
|
|
29
|
+
hstsPreload: options.hstsPreload ?? false,
|
|
30
|
+
csp: options.csp ?? "default-src 'self'",
|
|
31
|
+
referrerPolicy: options.referrerPolicy ?? 'no-referrer',
|
|
32
|
+
noSniff: options.noSniff ?? true,
|
|
33
|
+
frameOptions: options.frameOptions ?? 'SAMEORIGIN',
|
|
34
|
+
coop: options.coop ?? 'same-origin',
|
|
35
|
+
corp: options.corp ?? 'same-origin',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
return async (ctx, next) => {
|
|
39
|
+
// Apply headers early; they persist through the response.
|
|
40
|
+
if (opts.hstsMaxAge > 0) {
|
|
41
|
+
let hsts = `max-age=${opts.hstsMaxAge}`;
|
|
42
|
+
if (opts.hstsIncludeSubdomains) hsts += '; includeSubDomains';
|
|
43
|
+
if (opts.hstsPreload) hsts += '; preload';
|
|
44
|
+
ctx.setHeader('strict-transport-security', hsts);
|
|
45
|
+
}
|
|
46
|
+
if (opts.csp) ctx.setHeader('content-security-policy', opts.csp);
|
|
47
|
+
if (opts.referrerPolicy) ctx.setHeader('referrer-policy', opts.referrerPolicy);
|
|
48
|
+
if (opts.noSniff) ctx.setHeader('x-content-type-options', 'nosniff');
|
|
49
|
+
if (opts.frameOptions) ctx.setHeader('x-frame-options', opts.frameOptions);
|
|
50
|
+
if (opts.coop) ctx.setHeader('cross-origin-opener-policy', opts.coop);
|
|
51
|
+
if (opts.corp) ctx.setHeader('cross-origin-resource-policy', opts.corp);
|
|
52
|
+
ctx.setHeader('x-xss-protection', '0'); // disabled in favor of CSP
|
|
53
|
+
await next();
|
|
54
|
+
};
|
|
55
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export * from './cors.js';
|
|
2
|
+
export * from './csrf.js';
|
|
3
|
+
export * from './headers.js';
|
|
4
|
+
export * from './rateLimit.js';
|
|
5
|
+
export * from './password.js';
|
|
6
|
+
export * from './jwt.js';
|
|
7
|
+
export * from './session.js';
|
|
8
|
+
export * from './rbac.js';
|
|
9
|
+
export * from './oauth.js';
|
|
10
|
+
export * from './auth.js';
|
|
11
|
+
export * from './middleware.js';
|
package/src/jwt.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { SignJWT, jwtVerify, type JWTPayload } from 'jose';
|
|
2
|
+
|
|
3
|
+
export interface TokenPayload extends JWTPayload {
|
|
4
|
+
/** Subject (user id). */
|
|
5
|
+
sub: string;
|
|
6
|
+
/** Roles attached to the subject. */
|
|
7
|
+
roles?: string[];
|
|
8
|
+
/** Token kind discriminator. */
|
|
9
|
+
kind?: 'access' | 'refresh';
|
|
10
|
+
/** For refresh tokens: the session id they belong to. */
|
|
11
|
+
sid?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface JwtOptions {
|
|
15
|
+
/** Signing secret (string or raw bytes). Required. */
|
|
16
|
+
secret: string | Uint8Array;
|
|
17
|
+
/** Algorithm. Defaults to HS256. */
|
|
18
|
+
algorithm?: 'HS256' | 'HS384' | 'HS512';
|
|
19
|
+
/** Issuer. */
|
|
20
|
+
issuer?: string;
|
|
21
|
+
/** Audience. */
|
|
22
|
+
audience?: string;
|
|
23
|
+
/** Access-token TTL in seconds (default 15m). */
|
|
24
|
+
accessTtl?: number;
|
|
25
|
+
/** Refresh-token TTL in seconds (default 7d). */
|
|
26
|
+
refreshTtl?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const enc = (secret: string | Uint8Array): Uint8Array =>
|
|
30
|
+
typeof secret === 'string' ? new TextEncoder().encode(secret) : secret;
|
|
31
|
+
|
|
32
|
+
/** Sign an access token for a subject with roles (optionally bound to a session id). */
|
|
33
|
+
export async function signAccessToken(subject: string, roles: string[], opts: JwtOptions, sid?: string): Promise<string> {
|
|
34
|
+
const ttl = opts.accessTtl ?? 60 * 15;
|
|
35
|
+
const builder = new SignJWT({ kind: 'access', roles, sid })
|
|
36
|
+
.setProtectedHeader({ alg: opts.algorithm ?? 'HS256' })
|
|
37
|
+
.setSubject(subject)
|
|
38
|
+
.setIssuedAt()
|
|
39
|
+
.setExpirationTime(`${ttl}s`);
|
|
40
|
+
if (opts.issuer) builder.setIssuer(opts.issuer);
|
|
41
|
+
if (opts.audience) builder.setAudience(opts.audience);
|
|
42
|
+
return builder.sign(enc(opts.secret));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Sign a refresh token bound to a session id. */
|
|
46
|
+
export async function signRefreshToken(subject: string, sid: string, roles: string[], opts: JwtOptions): Promise<string> {
|
|
47
|
+
const ttl = opts.refreshTtl ?? 60 * 60 * 24 * 7;
|
|
48
|
+
const builder = new SignJWT({ kind: 'refresh', roles, sid })
|
|
49
|
+
.setProtectedHeader({ alg: opts.algorithm ?? 'HS256' })
|
|
50
|
+
.setSubject(subject)
|
|
51
|
+
.setIssuedAt()
|
|
52
|
+
.setExpirationTime(`${ttl}s`);
|
|
53
|
+
if (opts.issuer) builder.setIssuer(opts.issuer);
|
|
54
|
+
if (opts.audience) builder.setAudience(opts.audience);
|
|
55
|
+
return builder.sign(enc(opts.secret));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Verify a token and return its payload; throws on invalid/expired tokens. */
|
|
59
|
+
export async function verifyToken(token: string, opts: JwtOptions): Promise<TokenPayload> {
|
|
60
|
+
const { payload } = await jwtVerify(token, enc(opts.secret), {
|
|
61
|
+
issuer: opts.issuer,
|
|
62
|
+
audience: opts.audience,
|
|
63
|
+
});
|
|
64
|
+
return payload as TokenPayload;
|
|
65
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { Middleware, RequestContext } from '../../nexus-core/src/http/index.js';
|
|
2
|
+
import { AuthenticationError } from '../../nexus-core/src/index.js';
|
|
3
|
+
import type { AuthService } from './auth.js';
|
|
4
|
+
import type { TokenPair } from './auth.js';
|
|
5
|
+
|
|
6
|
+
export interface AuthCookieOptions {
|
|
7
|
+
/** Cookie names. */
|
|
8
|
+
accessTokenName?: string;
|
|
9
|
+
refreshTokenName?: string;
|
|
10
|
+
/** Cookie attributes. */
|
|
11
|
+
path?: string;
|
|
12
|
+
secure?: boolean;
|
|
13
|
+
sameSite?: 'strict' | 'lax' | 'none';
|
|
14
|
+
domain?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const DEFAULT_COOKIE: Required<AuthCookieOptions> = {
|
|
18
|
+
accessTokenName: 'nexus_at',
|
|
19
|
+
refreshTokenName: 'nexus_rt',
|
|
20
|
+
path: '/',
|
|
21
|
+
secure: false,
|
|
22
|
+
sameSite: 'lax',
|
|
23
|
+
domain: '',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Read a cookie value from the request's cookie header. */
|
|
27
|
+
export function readCookieValue(ctx: RequestContext, name: string): string | undefined {
|
|
28
|
+
const header = (ctx.headers['cookie'] as string) ?? '';
|
|
29
|
+
for (const part of header.split(';')) {
|
|
30
|
+
const eq = part.indexOf('=');
|
|
31
|
+
if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim();
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Set access + refresh tokens as httpOnly cookies (for cookie-based sessions). */
|
|
37
|
+
export function setAuthCookies(ctx: RequestContext, pair: TokenPair, opts: AuthCookieOptions = {}): void {
|
|
38
|
+
const o = { ...DEFAULT_COOKIE, ...opts };
|
|
39
|
+
const base = [`Path=${o.path}`, `SameSite=${o.sameSite}`, o.secure ? 'Secure' : '', o.domain ? `Domain=${o.domain}` : '', 'HttpOnly'].filter(Boolean).join('; ');
|
|
40
|
+
ctx.setHeader('set-cookie', [`${o.accessTokenName}=${pair.accessToken}; ${base}`, `${o.refreshTokenName}=${pair.refreshToken}; ${base}; Max-Age=${Math.floor((pair.refreshExpiresAt - Date.now()) / 1000)}`]);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Clear the auth cookies (logout). */
|
|
44
|
+
export function clearAuthCookies(ctx: RequestContext, opts: AuthCookieOptions = {}): void {
|
|
45
|
+
const o = { ...DEFAULT_COOKIE, ...opts };
|
|
46
|
+
const expired = `Path=${o.path}; Max-Age=0`;
|
|
47
|
+
ctx.setHeader('set-cookie', [`${o.accessTokenName}=; ${expired}`, `${o.refreshTokenName}=; ${expired}`]);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface AuthTokenOptions {
|
|
51
|
+
/** Cookie name for the access token (cookie-mode). Defaults to `nexus_at`. */
|
|
52
|
+
cookieName?: string;
|
|
53
|
+
/** If false, only the Authorization header is accepted (no cookie). */
|
|
54
|
+
allowCookie?: boolean;
|
|
55
|
+
/** If true, missing/invalid tokens throw 401; if false, the middleware is optional (no user set). */
|
|
56
|
+
required?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Authenticate the request by verifying a bearer access token from the
|
|
61
|
+
* `Authorization` header or (optionally) an httpOnly cookie. On success,
|
|
62
|
+
* `ctx.state.user = { id, roles }` is set for downstream `requireAuth` /
|
|
63
|
+
* `requireRole` / `requirePermission` middleware.
|
|
64
|
+
*/
|
|
65
|
+
export function authToken(service: AuthService, options: AuthTokenOptions = {}): Middleware {
|
|
66
|
+
const cookieName = options.cookieName ?? 'nexus_at';
|
|
67
|
+
const allowCookie = options.allowCookie ?? true;
|
|
68
|
+
const required = options.required ?? true;
|
|
69
|
+
|
|
70
|
+
return async (ctx, next) => {
|
|
71
|
+
const header = (ctx.headers['authorization'] as string) ?? '';
|
|
72
|
+
let token: string | undefined;
|
|
73
|
+
if (header.toLowerCase().startsWith('bearer ')) {
|
|
74
|
+
token = header.slice(7).trim();
|
|
75
|
+
} else if (allowCookie) {
|
|
76
|
+
token = readCookieValue(ctx, cookieName);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!token) {
|
|
80
|
+
if (required) throw new AuthenticationError();
|
|
81
|
+
await next();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const payload = await service.verifyAccessToken(token);
|
|
87
|
+
ctx.state.user = { id: payload.sub, roles: payload.roles ?? [], ...(payload as Record<string, unknown>) };
|
|
88
|
+
} catch (e) {
|
|
89
|
+
if (required) throw e instanceof AuthenticationError ? e : new AuthenticationError('Invalid token');
|
|
90
|
+
// optional mode: leave ctx.state.user unset
|
|
91
|
+
}
|
|
92
|
+
await next();
|
|
93
|
+
};
|
|
94
|
+
}
|