@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/src/oauth.ts ADDED
@@ -0,0 +1,199 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+
3
+ /** Normalized profile returned by every provider. */
4
+ export interface OAuthProfile {
5
+ provider: 'google' | 'facebook';
6
+ /** Stable per-provider user id (the provider's own subject id). */
7
+ providerUserId: string;
8
+ email?: string;
9
+ emailVerified?: boolean;
10
+ name?: string;
11
+ givenName?: string;
12
+ familyName?: string;
13
+ avatarUrl?: string;
14
+ raw: Record<string, unknown>;
15
+ }
16
+
17
+ export interface GoogleOAuthConfig {
18
+ clientId: string;
19
+ clientSecret: string;
20
+ /** Redirect URI registered in the Google console. */
21
+ redirectUri: string;
22
+ /** OAuth scopes (default: openid email profile). */
23
+ scope?: string[];
24
+ }
25
+
26
+ export interface FacebookOAuthConfig {
27
+ clientId: string;
28
+ clientSecret: string;
29
+ redirectUri: string;
30
+ scope?: string[];
31
+ /** Graph API version. */
32
+ apiVersion?: string;
33
+ }
34
+
35
+ const GOOGLE_AUTH = 'https://accounts.google.com/o/oauth2/v2/auth';
36
+ const GOOGLE_TOKEN = 'https://oauth2.googleapis.com/token';
37
+ const GOOGLE_PROFILE = 'https://openidconnect.googleapis.com/v1/userinfo';
38
+
39
+ // ── PKCE / state helpers ─────────────────────────────────────────────────────
40
+
41
+ /** Random URL-safe string for the OAuth `state` param (CSRF for the redirect). */
42
+ export function generateState(): string {
43
+ return randomBytes(24).toString('base64url');
44
+ }
45
+
46
+ /** Generate a PKCE code_verifier (43-128 chars, unreserved chars). */
47
+ export function generatePkceVerifier(): string {
48
+ return randomBytes(48).toString('base64url');
49
+ }
50
+
51
+ /** S256 code_challenge for a verifier. */
52
+ export function computePkceChallenge(verifier: string): string {
53
+ return createHash('sha256').update(verifier).digest('base64url');
54
+ }
55
+
56
+ /**
57
+ * Short-lived store mapping `state` → flow context (verifier, redirect target).
58
+ * The default in-memory impl is fine for a single process; a Redis-backed impl
59
+ * is wired in Phase 7 for multi-instance deployments.
60
+ */
61
+ export interface OAuthStateStore {
62
+ set(state: string, data: Record<string, unknown>, ttlMs: number): Promise<void>;
63
+ consume(state: string): Promise<Record<string, unknown> | null>;
64
+ }
65
+
66
+ export class MemoryOAuthStateStore implements OAuthStateStore {
67
+ private map = new Map<string, { data: Record<string, unknown>; expiresAt: number }>();
68
+ async set(state: string, data: Record<string, unknown>, ttlMs: number): Promise<void> {
69
+ this.map.set(state, { data, expiresAt: Date.now() + ttlMs });
70
+ }
71
+ async consume(state: string): Promise<Record<string, unknown> | null> {
72
+ const entry = this.map.get(state);
73
+ if (!entry) return null;
74
+ this.map.delete(state);
75
+ if (entry.expiresAt < Date.now()) return null;
76
+ return entry.data;
77
+ }
78
+ }
79
+
80
+ // ── Google ───────────────────────────────────────────────────────────────────
81
+
82
+ export function buildGoogleAuthUrl(
83
+ config: GoogleOAuthConfig,
84
+ opts: { state: string; verifier: string },
85
+ ): string {
86
+ const scope = (config.scope ?? ['openid', 'email', 'profile']).join(' ');
87
+ const params = new URLSearchParams({
88
+ client_id: config.clientId,
89
+ redirect_uri: config.redirectUri,
90
+ response_type: 'code',
91
+ scope,
92
+ state: opts.state,
93
+ code_challenge: computePkceChallenge(opts.verifier),
94
+ code_challenge_method: 'S256',
95
+ access_type: 'offline',
96
+ prompt: 'consent',
97
+ });
98
+ return `${GOOGLE_AUTH}?${params.toString()}`;
99
+ }
100
+
101
+ export async function exchangeGoogleCode(
102
+ code: string,
103
+ config: GoogleOAuthConfig,
104
+ verifier: string,
105
+ ): Promise<{ accessToken: string; refreshToken?: string; idToken?: string; expiresAt: number }> {
106
+ const body = new URLSearchParams({
107
+ code,
108
+ client_id: config.clientId,
109
+ client_secret: config.clientSecret,
110
+ redirect_uri: config.redirectUri,
111
+ grant_type: 'authorization_code',
112
+ code_verifier: verifier,
113
+ });
114
+ const res = await fetch(GOOGLE_TOKEN, {
115
+ method: 'POST',
116
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
117
+ body,
118
+ });
119
+ if (!res.ok) throw new Error(`Google token exchange failed: ${res.status} ${await res.text()}`);
120
+ const json = (await res.json()) as Record<string, unknown>;
121
+ const expiresIn = Number(json.expires_in ?? 3600);
122
+ return {
123
+ accessToken: String(json.access_token),
124
+ refreshToken: json.refresh_token ? String(json.refresh_token) : undefined,
125
+ idToken: json.id_token ? String(json.id_token) : undefined,
126
+ expiresAt: Date.now() + expiresIn * 1000,
127
+ };
128
+ }
129
+
130
+ export async function fetchGoogleProfile(accessToken: string): Promise<OAuthProfile> {
131
+ const res = await fetch(GOOGLE_PROFILE, { headers: { authorization: `Bearer ${accessToken}` } });
132
+ if (!res.ok) throw new Error(`Google profile fetch failed: ${res.status}`);
133
+ const raw = (await res.json()) as Record<string, unknown>;
134
+ return {
135
+ provider: 'google',
136
+ providerUserId: String(raw.sub),
137
+ email: raw.email ? String(raw.email) : undefined,
138
+ emailVerified: raw.email_verified === true || raw.email_verified === 'true',
139
+ name: raw.name ? String(raw.name) : undefined,
140
+ givenName: raw.given_name ? String(raw.given_name) : undefined,
141
+ familyName: raw.family_name ? String(raw.family_name) : undefined,
142
+ avatarUrl: raw.picture ? String(raw.picture) : undefined,
143
+ raw,
144
+ };
145
+ }
146
+
147
+ // ── Facebook ─────────────────────────────────────────────────────────────────
148
+
149
+ export function buildFacebookAuthUrl(config: FacebookOAuthConfig, state: string): string {
150
+ const version = config.apiVersion ?? 'v19.0';
151
+ const scope = (config.scope ?? ['email']).join(',');
152
+ const params = new URLSearchParams({
153
+ client_id: config.clientId,
154
+ redirect_uri: config.redirectUri,
155
+ response_type: 'code',
156
+ scope,
157
+ state,
158
+ });
159
+ return `https://www.facebook.com/${version}/dialog/oauth?${params.toString()}`;
160
+ }
161
+
162
+ export async function exchangeFacebookCode(
163
+ code: string,
164
+ config: FacebookOAuthConfig,
165
+ ): Promise<{ accessToken: string; expiresAt: number }> {
166
+ const version = config.apiVersion ?? 'v19.0';
167
+ const params = new URLSearchParams({
168
+ code,
169
+ client_id: config.clientId,
170
+ client_secret: config.clientSecret,
171
+ redirect_uri: config.redirectUri,
172
+ });
173
+ const res = await fetch(`https://graph.facebook.com/${version}/oauth/access_token?${params.toString()}`);
174
+ if (!res.ok) throw new Error(`Facebook token exchange failed: ${res.status} ${await res.text()}`);
175
+ const json = (await res.json()) as Record<string, unknown>;
176
+ const expiresIn = Number(json.expires_in ?? 3600);
177
+ return { accessToken: String(json.access_token), expiresAt: Date.now() + expiresIn * 1000 };
178
+ }
179
+
180
+ export async function fetchFacebookProfile(accessToken: string, apiVersion = 'v19.0'): Promise<OAuthProfile> {
181
+ const fields = 'id,name,email,first_name,last_name,picture';
182
+ const res = await fetch(`https://graph.facebook.com/${apiVersion}/me?fields=${fields}&access_token=${accessToken}`);
183
+ if (!res.ok) throw new Error(`Facebook profile fetch failed: ${res.status}`);
184
+ const raw = (await res.json()) as Record<string, unknown>;
185
+ return {
186
+ provider: 'facebook',
187
+ providerUserId: String(raw.id),
188
+ email: raw.email ? String(raw.email) : undefined,
189
+ emailVerified: !!raw.email, // Facebook marks email-verified accounts; treat presence as verified.
190
+ name: raw.name ? String(raw.name) : undefined,
191
+ givenName: raw.first_name ? String(raw.first_name) : undefined,
192
+ familyName: raw.last_name ? String(raw.last_name) : undefined,
193
+ avatarUrl:
194
+ raw.picture && typeof raw.picture === 'object'
195
+ ? String((raw.picture as { data?: { url?: string } }).data?.url ?? '')
196
+ : undefined,
197
+ raw,
198
+ };
199
+ }
@@ -0,0 +1,33 @@
1
+ /// <reference path="./bcryptjs.d.ts" />
2
+
3
+ import bcrypt from 'bcryptjs';
4
+
5
+ /**
6
+ * Password hashing using `bcryptjs` (pure-JavaScript, API-compatible with the
7
+ * native `bcrypt` binding). The pure-JS implementation is chosen deliberately
8
+ * so the framework builds on Windows without node-gyp / native toolchains;
9
+ * the cost parameter below keeps it within practical latency budgets.
10
+ */
11
+ const DEFAULT_ROUNDS = 12;
12
+
13
+ /** Hash a plaintext password with a salt cost of `rounds`. */
14
+ export async function hashPassword(plaintext: string, rounds = DEFAULT_ROUNDS): Promise<string> {
15
+ if (!plaintext || typeof plaintext !== 'string') throw new Error('Password must be a non-empty string.');
16
+ const salt = await bcrypt.genSalt(rounds);
17
+ return bcrypt.hash(plaintext, salt);
18
+ }
19
+
20
+ /** Verify a plaintext password against a previously-hashed value. */
21
+ export async function verifyPassword(plaintext: string, hash: string): Promise<boolean> {
22
+ if (!plaintext || !hash) return false;
23
+ try {
24
+ return await bcrypt.compare(plaintext, hash);
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+
30
+ /** True if `hash` looks like a bcrypt hash (so we can detect legacy / plaintext). */
31
+ export function isBcryptHash(hash: string): boolean {
32
+ return /^\$2[abxy]\$\d{2}\$/.test(hash);
33
+ }
@@ -0,0 +1,59 @@
1
+ import type { Middleware } from '../../nexus-core/src/http/index.js';
2
+ import { AuthenticationError } from '../../nexus-core/src/index.js';
3
+
4
+ export interface RateLimitOptions {
5
+ /** Time window in ms. */
6
+ windowMs: number;
7
+ /** Maximum requests per window per key. */
8
+ max: number;
9
+ /** Function to derive the rate-limit key (default: client IP). */
10
+ keyGenerator?: (ctx: import('../../nexus-core/src/http/index.js').RequestContext) => string;
11
+ /** Message returned when limited. */
12
+ message?: string;
13
+ }
14
+
15
+ /** Pluggable store — default is in-memory; Redis backend added in Phase 7. */
16
+ export interface RateLimitStore {
17
+ hit(key: string, windowMs: number): Promise<{ count: number; resetAt: number }>;
18
+ }
19
+
20
+ export function memoryStore(): RateLimitStore {
21
+ const buckets = new Map<string, { count: number; resetAt: number }>();
22
+ return {
23
+ async hit(key, windowMs) {
24
+ const now = Date.now();
25
+ let entry = buckets.get(key);
26
+ if (!entry || entry.resetAt <= now) {
27
+ entry = { count: 0, resetAt: now + windowMs };
28
+ buckets.set(key, entry);
29
+ }
30
+ entry.count++;
31
+ return { count: entry.count, resetAt: entry.resetAt };
32
+ },
33
+ };
34
+ }
35
+
36
+ /**
37
+ * Inbuilt rate limiter (fixed window). In-memory by default; pass a Redis-backed
38
+ * store for horizontal scaling. Sets standard `rate-limit-*` headers.
39
+ */
40
+ export function rateLimit(options: RateLimitOptions): Middleware {
41
+ const store = options.keyGenerator ? memoryStore() : memoryStore();
42
+ const keyGenerator =
43
+ options.keyGenerator ??
44
+ ((ctx) => (ctx.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ?? ctx.req.socket.remoteAddress ?? 'unknown');
45
+
46
+ return async (ctx, next) => {
47
+ const key = keyGenerator(ctx);
48
+ const { count, resetAt } = await store.hit(key, options.windowMs);
49
+ const remaining = Math.max(0, options.max - count);
50
+ ctx.setHeader('rate-limit-limit', String(options.max));
51
+ ctx.setHeader('rate-limit-remaining', String(remaining));
52
+ ctx.setHeader('rate-limit-reset', String(Math.ceil((resetAt - Date.now()) / 1000)));
53
+ if (count > options.max) {
54
+ ctx.setHeader('retry-after', String(Math.ceil((resetAt - Date.now()) / 1000)));
55
+ throw new AuthenticationError(options.message ?? 'Too many requests');
56
+ }
57
+ await next();
58
+ };
59
+ }
package/src/rbac.ts ADDED
@@ -0,0 +1,94 @@
1
+ import type { Middleware, RequestContext } from '../../nexus-core/src/http/index.js';
2
+ import { AuthorizationError, AuthenticationError } from '../../nexus-core/src/index.js';
3
+
4
+ /**
5
+ * Role-based access control. A `RoleRegistry` maps role names to the set of
6
+ * permissions they grant; roles can inherit from other roles via the
7
+ * `inherits` option. The `can(role, permission)` check resolves the full
8
+ * transitive permission set.
9
+ */
10
+ export interface RoleDefinition {
11
+ permissions?: string[];
12
+ /** Other roles whose permissions are inherited. */
13
+ inherits?: string[];
14
+ }
15
+
16
+ export class RoleRegistry {
17
+ private roles = new Map<string, RoleDefinition>();
18
+
19
+ define(name: string, def: RoleDefinition): this {
20
+ this.roles.set(name, def);
21
+ return this;
22
+ }
23
+
24
+ defineAll(map: Record<string, RoleDefinition>): this {
25
+ for (const [name, def] of Object.entries(map)) this.define(name, def);
26
+ return this;
27
+ }
28
+
29
+ /** Resolve the full transitive permission set for a role. */
30
+ permissionsFor(role: string, seen = new Set<string>()): Set<string> {
31
+ if (seen.has(role)) return new Set();
32
+ seen.add(role);
33
+ const def = this.roles.get(role);
34
+ if (!def) return new Set();
35
+ const out = new Set(def.permissions ?? []);
36
+ for (const parent of def.inherits ?? []) {
37
+ for (const p of this.permissionsFor(parent, seen)) out.add(p);
38
+ }
39
+ return out;
40
+ }
41
+
42
+ can(role: string, permission: string): boolean {
43
+ return this.permissionsFor(role).has(permission);
44
+ }
45
+
46
+ /** True if ANY of the user's roles grant the permission. */
47
+ canAny(roles: string[], permission: string): boolean {
48
+ return roles.some((r) => this.can(r, permission));
49
+ }
50
+ }
51
+
52
+ /** The shape of an authenticated principal stored on `ctx.state.user`. */
53
+ export interface AuthUser {
54
+ id: string;
55
+ roles: string[];
56
+ [key: string]: unknown;
57
+ }
58
+
59
+ /** Read the authenticated user from ctx.state, throwing 401 if absent. */
60
+ export function getUser(ctx: RequestContext): AuthUser {
61
+ const user = ctx.state.user as AuthUser | undefined;
62
+ if (!user) throw new AuthenticationError();
63
+ return user;
64
+ }
65
+
66
+ /** Middleware that requires an authenticated user (sets nothing; pairs with `authToken`). */
67
+ export function requireAuth(): Middleware {
68
+ return async (ctx, next) => {
69
+ getUser(ctx);
70
+ await next();
71
+ };
72
+ }
73
+
74
+ /** Middleware that requires the user to hold one of the given roles. */
75
+ export function requireRole(...roles: string[]): Middleware {
76
+ return async (ctx, next) => {
77
+ const user = getUser(ctx);
78
+ if (!roles.some((r) => user.roles.includes(r))) {
79
+ throw new AuthorizationError(`Requires one of roles: ${roles.join(', ')}`);
80
+ }
81
+ await next();
82
+ };
83
+ }
84
+
85
+ /** Middleware that requires a specific permission, checked against a registry. */
86
+ export function requirePermission(registry: RoleRegistry, permission: string): Middleware {
87
+ return async (ctx, next) => {
88
+ const user = getUser(ctx);
89
+ if (!registry.canAny(user.roles, permission)) {
90
+ throw new AuthorizationError(`Missing permission: ${permission}`);
91
+ }
92
+ await next();
93
+ };
94
+ }
package/src/session.ts ADDED
@@ -0,0 +1,91 @@
1
+ import { randomBytes } from 'node:crypto';
2
+
3
+ export interface Session {
4
+ id: string;
5
+ /** User id this session belongs to. */
6
+ userId: string;
7
+ /** Roles snapshot at session creation (for quick auth without a DB hit). */
8
+ roles: string[];
9
+ /** Refresh-token family id — used to detect reuse after rotation. */
10
+ familyId: string;
11
+ /** The current refresh token's jti (so a reused/old refresh token is detectable). */
12
+ currentJti?: string;
13
+ /** Creation timestamp (ms). */
14
+ createdAt: number;
15
+ /** Last-seen timestamp (ms). */
16
+ updatedAt: number;
17
+ /** Optional metadata (ip, userAgent). */
18
+ meta?: Record<string, unknown>;
19
+ }
20
+
21
+ /**
22
+ * Session store abstraction. The default `MemorySessionStore` is used in tests
23
+ * and single-process deployments; a Redis-backed implementation is wired in
24
+ * Phase 7 (`nexus-cache`) for horizontal scaling. Refresh-token rotation uses
25
+ * the `familyId` so that a stolen, already-rotated refresh token revokes the
26
+ * entire family on reuse detection.
27
+ */
28
+ export interface SessionStore {
29
+ create(userId: string, roles: string[], meta?: Record<string, unknown>): Promise<Session>;
30
+ get(id: string): Promise<Session | null>;
31
+ update(id: string, patch: Partial<Session>): Promise<void>;
32
+ destroy(id: string): Promise<void>;
33
+ /** Destroy every session in a refresh family (reuse detection / logout-all). */
34
+ destroyFamily(familyId: string): Promise<void>;
35
+ /** Destroy all sessions for a user (logout everywhere). */
36
+ destroyAllForUser(userId: string): Promise<void>;
37
+ }
38
+
39
+ export class MemorySessionStore implements SessionStore {
40
+ private sessions = new Map<string, Session>();
41
+ private byFamily = new Map<string, Set<string>>();
42
+ private byUser = new Map<string, Set<string>>();
43
+
44
+ async create(userId: string, roles: string[], meta?: Record<string, unknown>): Promise<Session> {
45
+ const id = randomBytes(18).toString('base64url');
46
+ const familyId = randomBytes(18).toString('base64url');
47
+ const now = Date.now();
48
+ const session: Session = { id, userId, roles, familyId, createdAt: now, updatedAt: now, meta };
49
+ this.sessions.set(id, session);
50
+ this.byFamily.set(familyId, new Set([id]));
51
+ let userSet = this.byUser.get(userId);
52
+ if (!userSet) {
53
+ userSet = new Set<string>();
54
+ this.byUser.set(userId, userSet);
55
+ }
56
+ userSet.add(id);
57
+ return session;
58
+ }
59
+
60
+ async get(id: string): Promise<Session | null> {
61
+ return this.sessions.get(id) ?? null;
62
+ }
63
+
64
+ async update(id: string, patch: Partial<Session>): Promise<void> {
65
+ const s = this.sessions.get(id);
66
+ if (!s) return;
67
+ Object.assign(s, patch, { updatedAt: Date.now() });
68
+ }
69
+
70
+ async destroy(id: string): Promise<void> {
71
+ const s = this.sessions.get(id);
72
+ if (!s) return;
73
+ this.sessions.delete(id);
74
+ this.byFamily.get(s.familyId)?.delete(id);
75
+ this.byUser.get(s.userId)?.delete(id);
76
+ }
77
+
78
+ async destroyFamily(familyId: string): Promise<void> {
79
+ const ids = this.byFamily.get(familyId);
80
+ if (!ids) return;
81
+ for (const id of ids) this.destroy(id);
82
+ this.byFamily.delete(familyId);
83
+ }
84
+
85
+ async destroyAllForUser(userId: string): Promise<void> {
86
+ const ids = this.byUser.get(userId);
87
+ if (!ids) return;
88
+ for (const id of ids) this.destroy(id);
89
+ this.byUser.delete(userId);
90
+ }
91
+ }