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/.env.example +7 -0
- package/LICENSE +21 -0
- package/README.md +232 -0
- package/docs/api-reference.md +203 -0
- package/docs/examples.md +225 -0
- package/index.js +15 -0
- package/package.json +37 -0
- package/src/adapters/base.js +81 -0
- package/src/adapters/memory.js +159 -0
- package/src/core/crypto.js +103 -0
- package/src/middleware/auth.js +127 -0
- package/src/services/auth-service.js +172 -0
- package/tests/adapters.test.js +355 -0
- package/tests/auth-service.test.js +287 -0
- package/tests/crypto.test.js +202 -0
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;
|