ryauth 1.0.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/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "ryauth",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "test": "cross-env NODE_OPTIONS=--experimental-vm-modules jest"
8
+ },
9
+ "keywords": ["authentication", "auth", "jwt", "security", "nodejs", "middleware", "argon2", "rbac"],
10
+ "author": "Ryan Pereira <ryanpereira499@gmail.com>",
11
+ "license": "MIT",
12
+ "description": "A modern, secure, and database-agnostic authentication library for Node.js with JWT tokens, Argon2 password hashing, and role-based access control.",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "https://github.com/ryanpereira49/RyAuth.git"
16
+ },
17
+ "bugs": {
18
+ "url": "https://github.com/ryanpereira49/RyAuth/issues"
19
+ },
20
+ "homepage": "https://github.com/ryanpereira49/RyAuth#readme",
21
+ "dependencies": {
22
+ "argon2": "^0.44.0",
23
+ "cookie": "^1.1.1",
24
+ "dotenv": "^17.2.3",
25
+ "jose": "^6.1.3",
26
+ "zod": "^4.2.1"
27
+ },
28
+ "devDependencies": {
29
+ "@jest/globals": "^30.2.0",
30
+ "cross-env": "^10.1.0",
31
+ "jest": "^30.2.0",
32
+ "supertest": "^7.1.4"
33
+ },
34
+ "engines": {
35
+ "node": ">=18.0.0"
36
+ }
37
+ }
@@ -0,0 +1,81 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * BaseAdapter class defining the contract for database operations
5
+ * This is an abstract class that concrete adapters must implement
6
+ */
7
+ export class BaseAdapter {
8
+ /**
9
+ * Finds a user by email
10
+ * @param {string} email - The email to search for
11
+ * @returns {Promise<object|null>} The user object or null if not found
12
+ */
13
+ async findUserByEmail(email) {
14
+ throw new Error('Method findUserByEmail() must be implemented');
15
+ }
16
+
17
+ /**
18
+ * Creates a new user
19
+ * @param {object} userData - User data including email and hashedPassword
20
+ * @returns {Promise<object>} The created user object
21
+ */
22
+ async createUser(userData) {
23
+ throw new Error('Method createUser() must be implemented');
24
+ }
25
+
26
+ /**
27
+ * Saves a refresh token for a user
28
+ * @param {string} userId - The user ID
29
+ * @param {string} token - The refresh token
30
+ * @param {Date} expiresAt - The expiration date
31
+ * @returns {Promise<void>}
32
+ */
33
+ async saveRefreshToken(userId, token, expiresAt) {
34
+ throw new Error('Method saveRefreshToken() must be implemented');
35
+ }
36
+
37
+ /**
38
+ * Checks if a refresh token is valid (not revoked and not expired)
39
+ * @param {string} token - The refresh token to check
40
+ * @returns {Promise<boolean>} True if the token is valid
41
+ */
42
+ async isRefreshTokenValid(token) {
43
+ throw new Error('Method isRefreshTokenValid() must be implemented');
44
+ }
45
+
46
+ /**
47
+ * Revokes a refresh token
48
+ * @param {string} token - The refresh token to revoke
49
+ * @returns {Promise<void>}
50
+ */
51
+ async revokeRefreshToken(token) {
52
+ throw new Error('Method revokeRefreshToken() must be implemented');
53
+ }
54
+
55
+ /**
56
+ * Revokes all refresh tokens for a user (for security incidents)
57
+ * @param {string} userId - The user ID
58
+ * @returns {Promise<void>}
59
+ */
60
+ async revokeAllUserSessions(userId) {
61
+ throw new Error('Method revokeAllUserSessions() must be implemented');
62
+ }
63
+ }
64
+
65
+ /**
66
+ * User schema for validation
67
+ */
68
+ export const userSchema = z.object({
69
+ email: z.string().email(),
70
+ hashedPassword: z.string(),
71
+ role: z.string().optional().default('user')
72
+ });
73
+
74
+ /**
75
+ * Refresh token schema for validation
76
+ */
77
+ export const refreshTokenSchema = z.object({
78
+ userId: z.string(),
79
+ token: z.string(),
80
+ expiresAt: z.date()
81
+ });
@@ -0,0 +1,159 @@
1
+ import { BaseAdapter, userSchema, refreshTokenSchema } from './base.js';
2
+
3
+ /**
4
+ * In-memory adapter implementation for testing
5
+ * Uses simple JavaScript objects to store data
6
+ */
7
+ export class MemoryAdapter extends BaseAdapter {
8
+ #users = new Map(); // email -> user object
9
+ #refreshTokens = new Map(); // token -> { userId, expiresAt }
10
+ #revokedTokens = new Set(); // token -> boolean
11
+
12
+ /**
13
+ * Finds a user by email
14
+ * @param {string} email - The email to search for
15
+ * @returns {Promise<object|null>} The user object or null if not found
16
+ */
17
+ async findUserByEmail(email) {
18
+ if (typeof email !== 'string') {
19
+ throw new Error('Email must be a string');
20
+ }
21
+ return this.#users.get(email) || null;
22
+ }
23
+
24
+ /**
25
+ * Creates a new user
26
+ * @param {object} userData - User data including email and hashedPassword
27
+ * @returns {Promise<object>} The created user object
28
+ */
29
+ async createUser(userData) {
30
+ // Validate input
31
+ const validated = userSchema.parse(userData);
32
+
33
+ if (this.#users.has(validated.email)) {
34
+ throw new Error('User with this email already exists');
35
+ }
36
+
37
+ const user = {
38
+ id: crypto.randomUUID(),
39
+ email: validated.email,
40
+ hashedPassword: validated.hashedPassword,
41
+ role: validated.role,
42
+ createdAt: new Date()
43
+ };
44
+
45
+ this.#users.set(validated.email, user);
46
+ return user;
47
+ }
48
+
49
+ /**
50
+ * Saves a refresh token for a user
51
+ * @param {string} userId - The user ID
52
+ * @param {string} token - The refresh token
53
+ * @param {Date} expiresAt - The expiration date
54
+ * @returns {Promise<void>}
55
+ */
56
+ async saveRefreshToken(userId, token, expiresAt) {
57
+ if (typeof userId !== 'string' || typeof token !== 'string' || !(expiresAt instanceof Date)) {
58
+ throw new Error('Invalid parameters');
59
+ }
60
+
61
+ // Validate token format
62
+ if (token.length < 10) {
63
+ throw new Error('Token too short');
64
+ }
65
+
66
+ this.#refreshTokens.set(token, { userId, expiresAt });
67
+ this.#revokedTokens.delete(token);
68
+ }
69
+
70
+ /**
71
+ * Checks if a refresh token is valid (not revoked and not expired)
72
+ * @param {string} token - The refresh token to check
73
+ * @returns {Promise<boolean>} True if the token is valid
74
+ */
75
+ async isRefreshTokenValid(token) {
76
+ if (typeof token !== 'string') {
77
+ throw new Error('Token must be a string');
78
+ }
79
+
80
+ const tokenData = this.#refreshTokens.get(token);
81
+
82
+ if (!tokenData) {
83
+ return false;
84
+ }
85
+
86
+ // Check if revoked
87
+ if (this.#revokedTokens.has(token)) {
88
+ return false;
89
+ }
90
+
91
+ // Check if expired
92
+ if (tokenData.expiresAt < new Date()) {
93
+ return false;
94
+ }
95
+
96
+ return true;
97
+ }
98
+
99
+ /**
100
+ * Revokes a refresh token
101
+ * @param {string} token - The refresh token to revoke
102
+ * @returns {Promise<void>}
103
+ */
104
+ async revokeRefreshToken(token) {
105
+ if (typeof token !== 'string') {
106
+ throw new Error('Token must be a string');
107
+ }
108
+ this.#revokedTokens.add(token);
109
+ }
110
+
111
+ /**
112
+ * Revokes all refresh tokens for a user (for security incidents)
113
+ * @param {string} userId - The user ID
114
+ * @returns {Promise<void>}
115
+ */
116
+ async revokeAllUserSessions(userId) {
117
+ if (typeof userId !== 'string') {
118
+ throw new Error('User ID must be a string');
119
+ }
120
+
121
+ // Find all tokens for this user and revoke them
122
+ for (const [token, tokenData] of this.#refreshTokens.entries()) {
123
+ if (tokenData.userId === userId) {
124
+ this.#revokedTokens.add(token);
125
+ }
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Helper method to clear all data (for testing)
131
+ * @returns {Promise<void>}
132
+ */
133
+ async clear() {
134
+ this.#users.clear();
135
+ this.#refreshTokens.clear();
136
+ this.#revokedTokens.clear();
137
+ }
138
+
139
+ /**
140
+ * Helper method to get user by ID (not part of BaseAdapter contract)
141
+ * @param {string} userId - The user ID
142
+ * @returns {Promise<object|null>} The user object or null if not found
143
+ */
144
+ async findUserById(userId) {
145
+ if (typeof userId !== 'string') {
146
+ throw new Error('User ID must be a string');
147
+ }
148
+
149
+ for (const user of this.#users.values()) {
150
+ if (user.id === userId) {
151
+ return user;
152
+ }
153
+ }
154
+ return null;
155
+ }
156
+ }
157
+
158
+ // Export a singleton instance for convenience
159
+ export const memoryAdapter = new MemoryAdapter();
@@ -0,0 +1,103 @@
1
+ import argon2 from 'argon2';
2
+ import { SignJWT, jwtVerify } from 'jose';
3
+ import dotenv from 'dotenv';
4
+
5
+ // Load environment variables
6
+ dotenv.config();
7
+
8
+ /**
9
+ * Hashes a plain text password using Argon2
10
+ * @param {string} plain - The plain text password to hash
11
+ * @returns {Promise<string>} The hashed password
12
+ */
13
+ export async function hashPassword(plain) {
14
+ if (typeof plain !== 'string') {
15
+ throw new Error('Password must be a string');
16
+ }
17
+ return await argon2.hash(plain);
18
+ }
19
+
20
+ /**
21
+ * Verifies a plain text password against a hashed password using Argon2
22
+ * @param {string} hash - The hashed password
23
+ * @param {string} plain - The plain text password to verify
24
+ * @returns {Promise<boolean>} True if the password matches, false otherwise
25
+ */
26
+ export async function verifyPassword(hash, plain) {
27
+ if (typeof hash !== 'string' || typeof plain !== 'string') {
28
+ throw new Error('Hash and plain text must be strings');
29
+ }
30
+ // Always compare to prevent timing attacks
31
+ return await argon2.verify(hash, plain);
32
+ }
33
+
34
+ /**
35
+ * Signs an access token with 15-minute expiry
36
+ * @param {object} payload - The payload to sign
37
+ * @returns {Promise<string>} The signed JWT
38
+ */
39
+ export async function signAccessToken(payload) {
40
+ const secret = new TextEncoder().encode(process.env.ACCESS_TOKEN_SECRET);
41
+
42
+ if (!secret || secret.length < 32) {
43
+ throw new Error('ACCESS_TOKEN_SECRET must be at least 32 characters');
44
+ }
45
+
46
+ return new SignJWT(payload)
47
+ .setProtectedHeader({ alg: 'HS256' })
48
+ .setIssuedAt()
49
+ .setExpirationTime('15m')
50
+ .setJti(crypto.randomUUID()) // Add unique JWT ID
51
+ .sign(secret);
52
+ }
53
+
54
+ /**
55
+ * Signs a refresh token with 7-day expiry
56
+ * @param {object} payload - The payload to sign
57
+ * @returns {Promise<string>} The signed JWT
58
+ */
59
+ export async function signRefreshToken(payload) {
60
+ const secret = new TextEncoder().encode(process.env.REFRESH_TOKEN_SECRET);
61
+
62
+ if (!secret || secret.length < 32) {
63
+ throw new Error('REFRESH_TOKEN_SECRET must be at least 32 characters');
64
+ }
65
+
66
+ return new SignJWT(payload)
67
+ .setProtectedHeader({ alg: 'HS256' })
68
+ .setIssuedAt()
69
+ .setExpirationTime('7d')
70
+ .setJti(crypto.randomUUID()) // Add unique JWT ID
71
+ .sign(secret);
72
+ }
73
+
74
+ /**
75
+ * Verifies a JWT token
76
+ * @param {string} token - The JWT token to verify
77
+ * @param {string} type - The token type ('access' or 'refresh')
78
+ * @returns {Promise<object>} The verified payload
79
+ */
80
+ export async function verifyJWT(token, type) {
81
+ if (typeof token !== 'string') {
82
+ throw new Error('Token must be a string');
83
+ }
84
+
85
+ const secret = new TextEncoder().encode(
86
+ type === 'access'
87
+ ? process.env.ACCESS_TOKEN_SECRET
88
+ : process.env.REFRESH_TOKEN_SECRET
89
+ );
90
+
91
+ if (!secret || secret.length < 32) {
92
+ throw new Error(`${type.toUpperCase()}_TOKEN_SECRET must be at least 32 characters`);
93
+ }
94
+
95
+ try {
96
+ const { payload } = await jwtVerify(token, secret, {
97
+ algorithms: ['HS256']
98
+ });
99
+ return payload;
100
+ } catch (error) {
101
+ throw new Error('Invalid token');
102
+ }
103
+ }
@@ -0,0 +1,127 @@
1
+ // RyAuth - Authentication Middleware
2
+ // Provides JWT validation and role-based access control
3
+
4
+ import { jwtVerify, importJWK, importSPKI } from 'jose';
5
+ import { z } from 'zod';
6
+
7
+ // Configuration schema
8
+ const configSchema = z.object({
9
+ accessTokenSecret: z.string().min(32),
10
+ refreshTokenSecret: z.string().min(32),
11
+ });
12
+
13
+ // Error response schema
14
+ const errorSchema = z.object({
15
+ error: z.string(),
16
+ });
17
+
18
+ // User payload schema
19
+ const userPayloadSchema = z.object({
20
+ userId: z.string(),
21
+ email: z.string().email(),
22
+ role: z.string(),
23
+ iat: z.number(),
24
+ exp: z.number(),
25
+ });
26
+
27
+ class AuthMiddleware {
28
+ #config;
29
+ #accessKey;
30
+ #refreshKey;
31
+
32
+ constructor(config) {
33
+ this.#config = configSchema.parse(config);
34
+ }
35
+
36
+ async #initializeKeys() {
37
+ if (!this.#accessKey || !this.#refreshKey) {
38
+ // Convert secrets to JWK format for jose
39
+ this.#accessKey = await importJWK({ k: this.#config.accessTokenSecret, kty: 'oct' });
40
+ this.#refreshKey = await importJWK({ k: this.#config.refreshTokenSecret, kty: 'oct' });
41
+ }
42
+ }
43
+
44
+ // Extract token from Authorization header
45
+ #extractToken(req) {
46
+ const authHeader = req.headers['authorization'];
47
+
48
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
49
+ return null;
50
+ }
51
+
52
+ return authHeader.substring(7); // Remove 'Bearer ' prefix
53
+ }
54
+
55
+ // Verify JWT token
56
+ async #verifyToken(token, key) {
57
+ try {
58
+ const result = await jwtVerify(token, key);
59
+ return userPayloadSchema.parse(result.payload);
60
+ } catch (error) {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ // Authentication middleware - validates JWT
66
+ async authenticate(req, res, next) {
67
+ await this.#initializeKeys();
68
+
69
+ const token = this.#extractToken(req);
70
+
71
+ if (!token) {
72
+ res.status(401).json(errorSchema.parse({ error: 'Unauthorized' }));
73
+ return;
74
+ }
75
+
76
+ // Verify token using access key
77
+ const payload = await this.#verifyToken(token, this.#accessKey);
78
+
79
+ if (!payload) {
80
+ res.status(403).json(errorSchema.parse({ error: 'Forbidden' }));
81
+ return;
82
+ }
83
+
84
+ // Check if token is expired
85
+ if (payload.exp * 1000 < Date.now()) {
86
+ res.status(401).json(errorSchema.parse({ error: 'Unauthorized' }));
87
+ return;
88
+ }
89
+
90
+ // Attach user to request
91
+ req.user = payload;
92
+ next();
93
+ }
94
+
95
+ // Authorization middleware - enforces role-based access control
96
+ authorize(...allowedRoles) {
97
+ return (req, res, next) => {
98
+ // Check if user is authenticated
99
+ if (!req.user) {
100
+ res.status(401).json(errorSchema.parse({ error: 'Unauthorized' }));
101
+ return;
102
+ }
103
+
104
+ // Check if user has required role
105
+ if (!allowedRoles.includes(req.user.role)) {
106
+ res.status(403).json(errorSchema.parse({ error: 'Forbidden' }));
107
+ return;
108
+ }
109
+
110
+ next();
111
+ };
112
+ }
113
+ }
114
+
115
+ // Export middleware factory
116
+ export function createAuthMiddleware(config) {
117
+ return new AuthMiddleware(config);
118
+ }
119
+
120
+ // Export middleware instances for convenience
121
+ export function authenticate(req, res, next) {
122
+ throw new Error('AuthMiddleware must be initialized with config before use');
123
+ }
124
+
125
+ export function authorize(...roles) {
126
+ throw new Error('AuthMiddleware must be initialized with config before use');
127
+ }
@@ -0,0 +1,172 @@
1
+ // Auth Service Implementation
2
+ // Handles user registration, login, and refresh token rotation
3
+ // Uses adapter pattern for database abstraction
4
+
5
+ import { z } from 'zod';
6
+ import { hashPassword, verifyPassword } from '../core/crypto.js';
7
+ import { signAccessToken, signRefreshToken, verifyJWT } from '../core/crypto.js';
8
+
9
+ // Zod validation schemas
10
+ const registerSchema = z.object({
11
+ email: z.string().email(),
12
+ password: z.string().min(8, 'Password must be at least 8 characters'),
13
+ });
14
+
15
+ const loginSchema = z.object({
16
+ email: z.string().email(),
17
+ password: z.string().min(1, 'Password is required'),
18
+ });
19
+
20
+ const refreshSchema = z.object({
21
+ refreshToken: z.string().min(10, 'Refresh token is required'),
22
+ });
23
+
24
+ /**
25
+ * AuthService class
26
+ * Orchestrates authentication flows using adapter pattern
27
+ */
28
+ export class AuthService {
29
+ /**
30
+ * Create AuthService instance
31
+ * @param {BaseAdapter} adapter - Database adapter implementation
32
+ */
33
+ constructor(adapter) {
34
+ this.adapter = adapter;
35
+ }
36
+
37
+ /**
38
+ * Register a new user
39
+ * @param {string} email - User's email address
40
+ * @param {string} password - User's password
41
+ * @returns {Promise<{success: true, userId: string}>} Success response
42
+ * @throws {Error} Validation errors or registration failures
43
+ */
44
+ async register(email, password) {
45
+ // Validate input
46
+ const validated = registerSchema.safeParse({ email, password });
47
+ if (!validated.success) {
48
+ throw new Error(validated.error.issues[0].message);
49
+ }
50
+
51
+ // Check if user already exists
52
+ const existingUser = await this.adapter.findUserByEmail(email);
53
+ if (existingUser) {
54
+ throw new Error('User already exists');
55
+ }
56
+
57
+ // Hash password
58
+ const hashedPassword = await hashPassword(password);
59
+
60
+ // Create user
61
+ const user = await this.adapter.createUser({
62
+ email: validated.data.email,
63
+ hashedPassword: hashedPassword,
64
+ });
65
+
66
+ return {
67
+ success: true,
68
+ userId: user.id,
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Login user and issue token pair
74
+ * @param {string} email - User's email address
75
+ * @param {string} password - User's password
76
+ * @returns {Promise<{success: true, accessToken: string, refreshToken: string}>} Token pair
77
+ * @throws {Error} Generic "Invalid credentials" for security
78
+ */
79
+ async login(email, password) {
80
+ // Validate input
81
+ const validated = loginSchema.safeParse({ email, password });
82
+ if (!validated.success) {
83
+ throw new Error(validated.error.issues[0].message);
84
+ }
85
+
86
+ // Find user by email
87
+ const user = await this.adapter.findUserByEmail(validated.data.email);
88
+
89
+ // Always perform password verification for timing safety
90
+ // Use a dummy hash if user doesn't exist to maintain consistent timing
91
+ const hashToCheck = user ? user.hashedPassword : '$argon2id$v=19$m=65536,t=3,p=4$ltv4rLoGLpCul8wO1xwJlQ$vfkMCoi/vx7JJF0BBMrzxR/RuGzzv9i4M4o1vYfDfqY';
92
+ const passwordValid = await verifyPassword(hashToCheck, validated.data.password);
93
+
94
+ // Check both user existence and password validity
95
+ if (!user || !passwordValid) {
96
+ throw new Error('Invalid credentials');
97
+ }
98
+
99
+ // Generate token pair
100
+ const accessToken = await signAccessToken({ userId: user.id, role: user.role });
101
+ const refreshToken = await signRefreshToken({ userId: user.id });
102
+
103
+ // Save refresh token
104
+ await this.adapter.saveRefreshToken(
105
+ user.id,
106
+ refreshToken,
107
+ new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days from now
108
+ );
109
+
110
+ return {
111
+ success: true,
112
+ accessToken,
113
+ refreshToken,
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Refresh access token using refresh token
119
+ * Implements automatic breach detection
120
+ * @param {string} refreshToken - Valid refresh token
121
+ * @returns {Promise<{success: true, accessToken: string, refreshToken: string}>} New token pair
122
+ * @throws {Error} If token is invalid or revoked
123
+ */
124
+ async refresh(refreshToken) {
125
+ // Validate input
126
+ const validated = refreshSchema.safeParse({ refreshToken });
127
+ if (!validated.success) {
128
+ throw new Error(validated.error.issues[0].message);
129
+ }
130
+
131
+ // Verify JWT signature and extract payload
132
+ let payload;
133
+ try {
134
+ payload = await verifyJWT(validated.data.refreshToken, 'refresh');
135
+ } catch (error) {
136
+ throw new Error('Invalid refresh token');
137
+ }
138
+
139
+ // Check if token is revoked or expired
140
+ const isValid = await this.adapter.isRefreshTokenValid(validated.data.refreshToken);
141
+ if (!isValid) {
142
+ // Automatic breach detection: revoke all tokens for this user
143
+ await this.adapter.revokeAllUserSessions(payload.userId);
144
+ throw new Error('Session revoked - please login again');
145
+ }
146
+
147
+ // Revoke old refresh token (token rotation)
148
+ await this.adapter.revokeRefreshToken(validated.data.refreshToken);
149
+
150
+ // Issue new token pair
151
+ const newAccessToken = await signAccessToken({
152
+ userId: payload.userId,
153
+ role: payload.role || 'user'
154
+ });
155
+ const newRefreshToken = await signRefreshToken({ userId: payload.userId });
156
+
157
+ // Save new refresh token
158
+ await this.adapter.saveRefreshToken(
159
+ payload.userId,
160
+ newRefreshToken,
161
+ new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days from now
162
+ );
163
+
164
+ return {
165
+ success: true,
166
+ accessToken: newAccessToken,
167
+ refreshToken: newRefreshToken,
168
+ };
169
+ }
170
+ }
171
+
172
+ export default AuthService;