session-collab-mcp 0.3.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/bin/session-collab-mcp +9 -0
- package/migrations/0001_init.sql +72 -0
- package/migrations/0002_auth.sql +56 -0
- package/migrations/0002_session_status.sql +10 -0
- package/package.json +39 -0
- package/src/auth/handlers.ts +326 -0
- package/src/auth/jwt.ts +112 -0
- package/src/auth/middleware.ts +144 -0
- package/src/auth/password.ts +84 -0
- package/src/auth/types.ts +70 -0
- package/src/cli.ts +140 -0
- package/src/db/auth-queries.ts +256 -0
- package/src/db/queries.ts +562 -0
- package/src/db/sqlite-adapter.ts +137 -0
- package/src/db/types.ts +137 -0
- package/src/frontend/app.ts +1181 -0
- package/src/index.ts +438 -0
- package/src/mcp/protocol.ts +99 -0
- package/src/mcp/server.ts +155 -0
- package/src/mcp/tools/claim.ts +358 -0
- package/src/mcp/tools/decision.ts +124 -0
- package/src/mcp/tools/message.ts +150 -0
- package/src/mcp/tools/session.ts +322 -0
- package/src/tokens/generator.ts +33 -0
- package/src/tokens/handlers.ts +113 -0
- package/src/utils/crypto.ts +82 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from 'child_process';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import { dirname, join } from 'path';
|
|
5
|
+
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const cli = join(__dirname, '..', 'src', 'cli.ts');
|
|
8
|
+
|
|
9
|
+
spawn('npx', ['tsx', cli], { stdio: 'inherit', shell: true }).on('exit', process.exit);
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
-- Session Collaboration MCP - Initial Schema
|
|
2
|
+
-- Database: Cloudflare D1 (SQLite-compatible)
|
|
3
|
+
|
|
4
|
+
-- Sessions table: track active Claude Code sessions
|
|
5
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
6
|
+
id TEXT PRIMARY KEY,
|
|
7
|
+
name TEXT,
|
|
8
|
+
project_root TEXT NOT NULL,
|
|
9
|
+
machine_id TEXT,
|
|
10
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
11
|
+
last_heartbeat TEXT DEFAULT (datetime('now')),
|
|
12
|
+
status TEXT DEFAULT 'active' CHECK (status IN ('active', 'inactive', 'terminated'))
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
|
|
16
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_root);
|
|
17
|
+
|
|
18
|
+
-- Claims table: WIP declarations
|
|
19
|
+
CREATE TABLE IF NOT EXISTS claims (
|
|
20
|
+
id TEXT PRIMARY KEY,
|
|
21
|
+
session_id TEXT NOT NULL,
|
|
22
|
+
intent TEXT NOT NULL,
|
|
23
|
+
scope TEXT DEFAULT 'medium' CHECK (scope IN ('small', 'medium', 'large')),
|
|
24
|
+
status TEXT DEFAULT 'active' CHECK (status IN ('active', 'completed', 'abandoned')),
|
|
25
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
26
|
+
updated_at TEXT DEFAULT (datetime('now')),
|
|
27
|
+
completed_summary TEXT,
|
|
28
|
+
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
CREATE INDEX IF NOT EXISTS idx_claims_session ON claims(session_id);
|
|
32
|
+
CREATE INDEX IF NOT EXISTS idx_claims_status ON claims(status);
|
|
33
|
+
|
|
34
|
+
-- Claim files: normalized file paths for efficient querying
|
|
35
|
+
CREATE TABLE IF NOT EXISTS claim_files (
|
|
36
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
37
|
+
claim_id TEXT NOT NULL,
|
|
38
|
+
file_path TEXT NOT NULL,
|
|
39
|
+
is_pattern INTEGER DEFAULT 0,
|
|
40
|
+
FOREIGN KEY (claim_id) REFERENCES claims(id) ON DELETE CASCADE,
|
|
41
|
+
UNIQUE(claim_id, file_path)
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
CREATE INDEX IF NOT EXISTS idx_claim_files_path ON claim_files(file_path);
|
|
45
|
+
CREATE INDEX IF NOT EXISTS idx_claim_files_claim ON claim_files(claim_id);
|
|
46
|
+
|
|
47
|
+
-- Messages table: inter-session communication
|
|
48
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
49
|
+
id TEXT PRIMARY KEY,
|
|
50
|
+
from_session_id TEXT NOT NULL,
|
|
51
|
+
to_session_id TEXT,
|
|
52
|
+
content TEXT NOT NULL,
|
|
53
|
+
read_at TEXT,
|
|
54
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
55
|
+
FOREIGN KEY (from_session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
CREATE INDEX IF NOT EXISTS idx_messages_to ON messages(to_session_id);
|
|
59
|
+
CREATE INDEX IF NOT EXISTS idx_messages_unread ON messages(to_session_id, read_at);
|
|
60
|
+
|
|
61
|
+
-- Decisions table: architectural decisions log (optional feature)
|
|
62
|
+
CREATE TABLE IF NOT EXISTS decisions (
|
|
63
|
+
id TEXT PRIMARY KEY,
|
|
64
|
+
session_id TEXT NOT NULL,
|
|
65
|
+
category TEXT CHECK (category IN ('architecture', 'naming', 'api', 'database', 'ui', 'other')),
|
|
66
|
+
title TEXT NOT NULL,
|
|
67
|
+
description TEXT NOT NULL,
|
|
68
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
69
|
+
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
CREATE INDEX IF NOT EXISTS idx_decisions_category ON decisions(category);
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
-- Authentication & API Token Schema
|
|
2
|
+
-- Migration: 0002_auth.sql
|
|
3
|
+
|
|
4
|
+
-- Users table: user accounts
|
|
5
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
6
|
+
id TEXT PRIMARY KEY,
|
|
7
|
+
email TEXT NOT NULL UNIQUE,
|
|
8
|
+
password_hash TEXT NOT NULL,
|
|
9
|
+
display_name TEXT,
|
|
10
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
11
|
+
updated_at TEXT DEFAULT (datetime('now')),
|
|
12
|
+
last_login_at TEXT,
|
|
13
|
+
status TEXT DEFAULT 'active' CHECK (status IN ('active', 'suspended', 'deleted'))
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
|
17
|
+
CREATE INDEX IF NOT EXISTS idx_users_status ON users(status);
|
|
18
|
+
|
|
19
|
+
-- API Tokens table: user API keys for MCP authentication
|
|
20
|
+
CREATE TABLE IF NOT EXISTS api_tokens (
|
|
21
|
+
id TEXT PRIMARY KEY,
|
|
22
|
+
user_id TEXT NOT NULL,
|
|
23
|
+
name TEXT NOT NULL,
|
|
24
|
+
token_hash TEXT NOT NULL,
|
|
25
|
+
token_prefix TEXT NOT NULL,
|
|
26
|
+
scopes TEXT DEFAULT '["mcp"]',
|
|
27
|
+
last_used_at TEXT,
|
|
28
|
+
expires_at TEXT,
|
|
29
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
30
|
+
revoked_at TEXT,
|
|
31
|
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id);
|
|
35
|
+
CREATE INDEX IF NOT EXISTS idx_api_tokens_prefix ON api_tokens(token_prefix);
|
|
36
|
+
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
|
|
37
|
+
|
|
38
|
+
-- Refresh Tokens table: JWT refresh tokens (revocable)
|
|
39
|
+
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
|
40
|
+
id TEXT PRIMARY KEY,
|
|
41
|
+
user_id TEXT NOT NULL,
|
|
42
|
+
token_hash TEXT NOT NULL,
|
|
43
|
+
expires_at TEXT NOT NULL,
|
|
44
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
45
|
+
revoked_at TEXT,
|
|
46
|
+
user_agent TEXT,
|
|
47
|
+
ip_address TEXT,
|
|
48
|
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
|
|
52
|
+
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash);
|
|
53
|
+
|
|
54
|
+
-- Add user_id column to sessions table for user association
|
|
55
|
+
ALTER TABLE sessions ADD COLUMN user_id TEXT REFERENCES users(id);
|
|
56
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
-- Add work status tracking to sessions
|
|
2
|
+
-- Enables real-time visibility into what each session is working on
|
|
3
|
+
|
|
4
|
+
-- Add new columns to sessions table
|
|
5
|
+
ALTER TABLE sessions ADD COLUMN current_task TEXT;
|
|
6
|
+
ALTER TABLE sessions ADD COLUMN progress TEXT; -- JSON: {"completed": 3, "total": 5, "percentage": 60}
|
|
7
|
+
ALTER TABLE sessions ADD COLUMN todos TEXT; -- JSON array of todo items
|
|
8
|
+
|
|
9
|
+
-- Create index for faster queries on active sessions with tasks
|
|
10
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_active_task ON sessions(status, current_task) WHERE status = 'active';
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "session-collab-mcp",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "MCP server for Claude Code session collaboration - prevents conflicts between parallel sessions",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"session-collab-mcp": "./bin/session-collab-mcp"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"src",
|
|
12
|
+
"migrations"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsup",
|
|
16
|
+
"start": "node dist/cli.js",
|
|
17
|
+
"start:dev": "tsx src/cli.ts",
|
|
18
|
+
"prepublishOnly": "npm run build",
|
|
19
|
+
"dev": "wrangler dev",
|
|
20
|
+
"deploy": "wrangler deploy",
|
|
21
|
+
"typecheck": "tsc --noEmit",
|
|
22
|
+
"lint": "eslint src/",
|
|
23
|
+
"test": "vitest"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"better-sqlite3": "^11.7.0",
|
|
27
|
+
"tsx": "^4.19.2",
|
|
28
|
+
"zod": "^3.24.1"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@cloudflare/workers-types": "^4.20241218.0",
|
|
32
|
+
"@types/better-sqlite3": "^7.6.12",
|
|
33
|
+
"eslint": "^9.17.0",
|
|
34
|
+
"tsup": "^8.5.1",
|
|
35
|
+
"typescript": "^5.7.2",
|
|
36
|
+
"vitest": "^2.1.8",
|
|
37
|
+
"wrangler": "^3.99.0"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
// Authentication API handlers
|
|
2
|
+
|
|
3
|
+
import type { D1Database } from '@cloudflare/workers-types';
|
|
4
|
+
import { hashPassword, verifyPassword, validatePasswordStrength } from './password';
|
|
5
|
+
import { createAccessToken, createRefreshToken, verifyJwt, getTokenExpiry } from './jwt';
|
|
6
|
+
import {
|
|
7
|
+
createUser,
|
|
8
|
+
getUserByEmail,
|
|
9
|
+
getUserById,
|
|
10
|
+
updateUserLastLogin,
|
|
11
|
+
updateUserPassword,
|
|
12
|
+
updateUserProfile,
|
|
13
|
+
toUserPublic,
|
|
14
|
+
createRefreshToken as createRefreshTokenDb,
|
|
15
|
+
getRefreshTokenByHash,
|
|
16
|
+
revokeRefreshToken,
|
|
17
|
+
revokeAllUserRefreshTokens,
|
|
18
|
+
} from '../db/auth-queries';
|
|
19
|
+
import { sha256 } from '../utils/crypto';
|
|
20
|
+
import {
|
|
21
|
+
RegisterRequestSchema,
|
|
22
|
+
LoginRequestSchema,
|
|
23
|
+
RefreshRequestSchema,
|
|
24
|
+
UpdateProfileRequestSchema,
|
|
25
|
+
ChangePasswordRequestSchema,
|
|
26
|
+
type AuthResponse,
|
|
27
|
+
type UserResponse,
|
|
28
|
+
type AuthContext,
|
|
29
|
+
} from './types';
|
|
30
|
+
|
|
31
|
+
interface HandlerContext {
|
|
32
|
+
db: D1Database;
|
|
33
|
+
jwtSecret: string;
|
|
34
|
+
request: Request;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// CORS headers
|
|
38
|
+
const corsHeaders = {
|
|
39
|
+
'Access-Control-Allow-Origin': '*',
|
|
40
|
+
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
|
41
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
45
|
+
return new Response(JSON.stringify(body), {
|
|
46
|
+
status,
|
|
47
|
+
headers: { 'Content-Type': 'application/json', ...corsHeaders },
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function errorResponse(error: string, code: string, status: number, details?: { field: string; message: string }[]): Response {
|
|
52
|
+
return jsonResponse({ error, code, details }, status);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* POST /auth/register
|
|
57
|
+
*/
|
|
58
|
+
export async function handleRegister(ctx: HandlerContext): Promise<Response> {
|
|
59
|
+
const body = await ctx.request.json();
|
|
60
|
+
const parsed = RegisterRequestSchema.safeParse(body);
|
|
61
|
+
|
|
62
|
+
if (!parsed.success) {
|
|
63
|
+
const details = parsed.error.issues.map((i) => ({
|
|
64
|
+
field: i.path.join('.'),
|
|
65
|
+
message: i.message,
|
|
66
|
+
}));
|
|
67
|
+
return errorResponse('Validation failed', 'ERR_VALIDATION', 422, details);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const { email, password, display_name } = parsed.data;
|
|
71
|
+
|
|
72
|
+
// Validate password strength
|
|
73
|
+
const passwordError = validatePasswordStrength(password);
|
|
74
|
+
if (passwordError) {
|
|
75
|
+
return errorResponse(passwordError, 'ERR_WEAK_PASSWORD', 422, [{ field: 'password', message: passwordError }]);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Check if email already exists
|
|
79
|
+
const existingUser = await getUserByEmail(ctx.db, email);
|
|
80
|
+
if (existingUser) {
|
|
81
|
+
return errorResponse('Email already registered', 'ERR_EMAIL_EXISTS', 409, [{ field: 'email', message: 'Email already registered' }]);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Create user
|
|
85
|
+
const passwordHash = await hashPassword(password);
|
|
86
|
+
const user = await createUser(ctx.db, {
|
|
87
|
+
email,
|
|
88
|
+
password_hash: passwordHash,
|
|
89
|
+
display_name,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Create tokens
|
|
93
|
+
const accessToken = await createAccessToken(user.id, ctx.jwtSecret);
|
|
94
|
+
const refreshToken = await createRefreshToken(user.id, ctx.jwtSecret);
|
|
95
|
+
const refreshTokenHash = await sha256(refreshToken);
|
|
96
|
+
const { refreshToken: refreshExpiry } = getTokenExpiry();
|
|
97
|
+
|
|
98
|
+
// Store refresh token
|
|
99
|
+
await createRefreshTokenDb(ctx.db, {
|
|
100
|
+
user_id: user.id,
|
|
101
|
+
token_hash: refreshTokenHash,
|
|
102
|
+
expires_at: new Date(Date.now() + refreshExpiry * 1000).toISOString(),
|
|
103
|
+
user_agent: ctx.request.headers.get('User-Agent') ?? undefined,
|
|
104
|
+
ip_address: ctx.request.headers.get('CF-Connecting-IP') ?? undefined,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const response: AuthResponse = {
|
|
108
|
+
user: toUserPublic(user),
|
|
109
|
+
access_token: accessToken,
|
|
110
|
+
refresh_token: refreshToken,
|
|
111
|
+
expires_in: getTokenExpiry().accessToken,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
return jsonResponse(response, 201);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* POST /auth/login
|
|
119
|
+
*/
|
|
120
|
+
export async function handleLogin(ctx: HandlerContext): Promise<Response> {
|
|
121
|
+
const body = await ctx.request.json();
|
|
122
|
+
const parsed = LoginRequestSchema.safeParse(body);
|
|
123
|
+
|
|
124
|
+
if (!parsed.success) {
|
|
125
|
+
const details = parsed.error.issues.map((i) => ({
|
|
126
|
+
field: i.path.join('.'),
|
|
127
|
+
message: i.message,
|
|
128
|
+
}));
|
|
129
|
+
return errorResponse('Validation failed', 'ERR_VALIDATION', 422, details);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const { email, password } = parsed.data;
|
|
133
|
+
|
|
134
|
+
// Find user
|
|
135
|
+
const user = await getUserByEmail(ctx.db, email);
|
|
136
|
+
if (!user) {
|
|
137
|
+
return errorResponse('Invalid email or password', 'ERR_INVALID_CREDENTIALS', 401);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Verify password
|
|
141
|
+
const valid = await verifyPassword(password, user.password_hash);
|
|
142
|
+
if (!valid) {
|
|
143
|
+
return errorResponse('Invalid email or password', 'ERR_INVALID_CREDENTIALS', 401);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Update last login
|
|
147
|
+
await updateUserLastLogin(ctx.db, user.id);
|
|
148
|
+
|
|
149
|
+
// Create tokens
|
|
150
|
+
const accessToken = await createAccessToken(user.id, ctx.jwtSecret);
|
|
151
|
+
const refreshToken = await createRefreshToken(user.id, ctx.jwtSecret);
|
|
152
|
+
const refreshTokenHash = await sha256(refreshToken);
|
|
153
|
+
const { refreshToken: refreshExpiry } = getTokenExpiry();
|
|
154
|
+
|
|
155
|
+
// Store refresh token
|
|
156
|
+
await createRefreshTokenDb(ctx.db, {
|
|
157
|
+
user_id: user.id,
|
|
158
|
+
token_hash: refreshTokenHash,
|
|
159
|
+
expires_at: new Date(Date.now() + refreshExpiry * 1000).toISOString(),
|
|
160
|
+
user_agent: ctx.request.headers.get('User-Agent') ?? undefined,
|
|
161
|
+
ip_address: ctx.request.headers.get('CF-Connecting-IP') ?? undefined,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const response: AuthResponse = {
|
|
165
|
+
user: toUserPublic(user),
|
|
166
|
+
access_token: accessToken,
|
|
167
|
+
refresh_token: refreshToken,
|
|
168
|
+
expires_in: getTokenExpiry().accessToken,
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
return jsonResponse(response);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* POST /auth/refresh
|
|
176
|
+
*/
|
|
177
|
+
export async function handleRefresh(ctx: HandlerContext): Promise<Response> {
|
|
178
|
+
const body = await ctx.request.json();
|
|
179
|
+
const parsed = RefreshRequestSchema.safeParse(body);
|
|
180
|
+
|
|
181
|
+
if (!parsed.success) {
|
|
182
|
+
return errorResponse('Invalid request', 'ERR_VALIDATION', 422);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const { refresh_token } = parsed.data;
|
|
186
|
+
|
|
187
|
+
// Verify JWT
|
|
188
|
+
const payload = await verifyJwt(refresh_token, ctx.jwtSecret);
|
|
189
|
+
if (!payload || payload.type !== 'refresh') {
|
|
190
|
+
return errorResponse('Invalid refresh token', 'ERR_INVALID_TOKEN', 401);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Check if refresh token is in database (not revoked)
|
|
194
|
+
const refreshTokenHash = await sha256(refresh_token);
|
|
195
|
+
const storedToken = await getRefreshTokenByHash(ctx.db, refreshTokenHash);
|
|
196
|
+
if (!storedToken) {
|
|
197
|
+
return errorResponse('Refresh token has been revoked', 'ERR_TOKEN_REVOKED', 401);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Get user
|
|
201
|
+
const user = await getUserById(ctx.db, payload.sub);
|
|
202
|
+
if (!user) {
|
|
203
|
+
return errorResponse('User not found', 'ERR_USER_NOT_FOUND', 404);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Revoke old refresh token
|
|
207
|
+
await revokeRefreshToken(ctx.db, storedToken.id);
|
|
208
|
+
|
|
209
|
+
// Create new tokens
|
|
210
|
+
const accessToken = await createAccessToken(user.id, ctx.jwtSecret);
|
|
211
|
+
const newRefreshToken = await createRefreshToken(user.id, ctx.jwtSecret);
|
|
212
|
+
const newRefreshTokenHash = await sha256(newRefreshToken);
|
|
213
|
+
const { refreshToken: refreshExpiry } = getTokenExpiry();
|
|
214
|
+
|
|
215
|
+
// Store new refresh token
|
|
216
|
+
await createRefreshTokenDb(ctx.db, {
|
|
217
|
+
user_id: user.id,
|
|
218
|
+
token_hash: newRefreshTokenHash,
|
|
219
|
+
expires_at: new Date(Date.now() + refreshExpiry * 1000).toISOString(),
|
|
220
|
+
user_agent: ctx.request.headers.get('User-Agent') ?? undefined,
|
|
221
|
+
ip_address: ctx.request.headers.get('CF-Connecting-IP') ?? undefined,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const response: AuthResponse = {
|
|
225
|
+
user: toUserPublic(user),
|
|
226
|
+
access_token: accessToken,
|
|
227
|
+
refresh_token: newRefreshToken,
|
|
228
|
+
expires_in: getTokenExpiry().accessToken,
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
return jsonResponse(response);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* POST /auth/logout
|
|
236
|
+
*/
|
|
237
|
+
export async function handleLogout(ctx: HandlerContext, authContext: AuthContext): Promise<Response> {
|
|
238
|
+
// Revoke all refresh tokens for user
|
|
239
|
+
await revokeAllUserRefreshTokens(ctx.db, authContext.userId);
|
|
240
|
+
|
|
241
|
+
return jsonResponse({ message: 'Logged out successfully' });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* GET /auth/me
|
|
246
|
+
*/
|
|
247
|
+
export async function handleGetMe(ctx: HandlerContext, authContext: AuthContext): Promise<Response> {
|
|
248
|
+
const user = await getUserById(ctx.db, authContext.userId);
|
|
249
|
+
if (!user) {
|
|
250
|
+
return errorResponse('User not found', 'ERR_USER_NOT_FOUND', 404);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const response: UserResponse = toUserPublic(user);
|
|
254
|
+
return jsonResponse(response);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* PUT /auth/me
|
|
259
|
+
*/
|
|
260
|
+
export async function handleUpdateMe(ctx: HandlerContext, authContext: AuthContext): Promise<Response> {
|
|
261
|
+
const body = await ctx.request.json();
|
|
262
|
+
const parsed = UpdateProfileRequestSchema.safeParse(body);
|
|
263
|
+
|
|
264
|
+
if (!parsed.success) {
|
|
265
|
+
const details = parsed.error.issues.map((i) => ({
|
|
266
|
+
field: i.path.join('.'),
|
|
267
|
+
message: i.message,
|
|
268
|
+
}));
|
|
269
|
+
return errorResponse('Validation failed', 'ERR_VALIDATION', 422, details);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
await updateUserProfile(ctx.db, authContext.userId, parsed.data);
|
|
273
|
+
|
|
274
|
+
const user = await getUserById(ctx.db, authContext.userId);
|
|
275
|
+
if (!user) {
|
|
276
|
+
return errorResponse('User not found', 'ERR_USER_NOT_FOUND', 404);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const response: UserResponse = toUserPublic(user);
|
|
280
|
+
return jsonResponse(response);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* PUT /auth/password
|
|
285
|
+
*/
|
|
286
|
+
export async function handleChangePassword(ctx: HandlerContext, authContext: AuthContext): Promise<Response> {
|
|
287
|
+
const body = await ctx.request.json();
|
|
288
|
+
const parsed = ChangePasswordRequestSchema.safeParse(body);
|
|
289
|
+
|
|
290
|
+
if (!parsed.success) {
|
|
291
|
+
const details = parsed.error.issues.map((i) => ({
|
|
292
|
+
field: i.path.join('.'),
|
|
293
|
+
message: i.message,
|
|
294
|
+
}));
|
|
295
|
+
return errorResponse('Validation failed', 'ERR_VALIDATION', 422, details);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const { current_password, new_password } = parsed.data;
|
|
299
|
+
|
|
300
|
+
// Validate new password strength
|
|
301
|
+
const passwordError = validatePasswordStrength(new_password);
|
|
302
|
+
if (passwordError) {
|
|
303
|
+
return errorResponse(passwordError, 'ERR_WEAK_PASSWORD', 422, [{ field: 'new_password', message: passwordError }]);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Get user
|
|
307
|
+
const user = await getUserById(ctx.db, authContext.userId);
|
|
308
|
+
if (!user) {
|
|
309
|
+
return errorResponse('User not found', 'ERR_USER_NOT_FOUND', 404);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Verify current password
|
|
313
|
+
const valid = await verifyPassword(current_password, user.password_hash);
|
|
314
|
+
if (!valid) {
|
|
315
|
+
return errorResponse('Current password is incorrect', 'ERR_INVALID_PASSWORD', 401, [{ field: 'current_password', message: 'Current password is incorrect' }]);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Update password
|
|
319
|
+
const newPasswordHash = await hashPassword(new_password);
|
|
320
|
+
await updateUserPassword(ctx.db, authContext.userId, newPasswordHash);
|
|
321
|
+
|
|
322
|
+
// Revoke all refresh tokens (force re-login)
|
|
323
|
+
await revokeAllUserRefreshTokens(ctx.db, authContext.userId);
|
|
324
|
+
|
|
325
|
+
return jsonResponse({ message: 'Password changed successfully. Please login again.' });
|
|
326
|
+
}
|
package/src/auth/jwt.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// JWT utilities using Web Crypto API (HS256)
|
|
2
|
+
|
|
3
|
+
import { base64UrlEncode, base64UrlDecode } from '../utils/crypto';
|
|
4
|
+
|
|
5
|
+
export interface JwtPayload {
|
|
6
|
+
sub: string; // user_id
|
|
7
|
+
iat: number; // issued at (unix timestamp)
|
|
8
|
+
exp: number; // expires at (unix timestamp)
|
|
9
|
+
type: 'access' | 'refresh';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const JWT_ALGORITHM = 'HS256';
|
|
13
|
+
const ACCESS_TOKEN_EXPIRY = 15 * 60; // 15 minutes
|
|
14
|
+
const REFRESH_TOKEN_EXPIRY = 7 * 24 * 60 * 60; // 7 days
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Sign a JWT using HS256
|
|
18
|
+
*/
|
|
19
|
+
export async function signJwt(payload: JwtPayload, secret: string): Promise<string> {
|
|
20
|
+
const header = { alg: JWT_ALGORITHM, typ: 'JWT' };
|
|
21
|
+
const encodedHeader = base64UrlEncode(JSON.stringify(header));
|
|
22
|
+
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
|
|
23
|
+
|
|
24
|
+
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
25
|
+
|
|
26
|
+
const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${encodedHeader}.${encodedPayload}`));
|
|
27
|
+
|
|
28
|
+
return `${encodedHeader}.${encodedPayload}.${base64UrlEncode(signature)}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Verify and decode a JWT
|
|
33
|
+
* Returns null if invalid or expired
|
|
34
|
+
*/
|
|
35
|
+
export async function verifyJwt(token: string, secret: string): Promise<JwtPayload | null> {
|
|
36
|
+
const parts = token.split('.');
|
|
37
|
+
if (parts.length !== 3) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const [encodedHeader, encodedPayload, encodedSignature] = parts;
|
|
42
|
+
|
|
43
|
+
// Verify signature
|
|
44
|
+
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
|
|
45
|
+
|
|
46
|
+
// Decode signature from base64url
|
|
47
|
+
const signatureStr = base64UrlDecode(encodedSignature);
|
|
48
|
+
const signature = new Uint8Array(signatureStr.length);
|
|
49
|
+
for (let i = 0; i < signatureStr.length; i++) {
|
|
50
|
+
signature[i] = signatureStr.charCodeAt(i);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const valid = await crypto.subtle.verify('HMAC', key, signature, new TextEncoder().encode(`${encodedHeader}.${encodedPayload}`));
|
|
54
|
+
|
|
55
|
+
if (!valid) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Decode and parse payload
|
|
60
|
+
try {
|
|
61
|
+
const payloadStr = base64UrlDecode(encodedPayload);
|
|
62
|
+
const payload = JSON.parse(payloadStr) as JwtPayload;
|
|
63
|
+
|
|
64
|
+
// Check expiration
|
|
65
|
+
const now = Math.floor(Date.now() / 1000);
|
|
66
|
+
if (payload.exp < now) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return payload;
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Create an access token
|
|
78
|
+
*/
|
|
79
|
+
export async function createAccessToken(userId: string, secret: string): Promise<string> {
|
|
80
|
+
const now = Math.floor(Date.now() / 1000);
|
|
81
|
+
const payload: JwtPayload = {
|
|
82
|
+
sub: userId,
|
|
83
|
+
iat: now,
|
|
84
|
+
exp: now + ACCESS_TOKEN_EXPIRY,
|
|
85
|
+
type: 'access',
|
|
86
|
+
};
|
|
87
|
+
return signJwt(payload, secret);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Create a refresh token
|
|
92
|
+
*/
|
|
93
|
+
export async function createRefreshToken(userId: string, secret: string): Promise<string> {
|
|
94
|
+
const now = Math.floor(Date.now() / 1000);
|
|
95
|
+
const payload: JwtPayload = {
|
|
96
|
+
sub: userId,
|
|
97
|
+
iat: now,
|
|
98
|
+
exp: now + REFRESH_TOKEN_EXPIRY,
|
|
99
|
+
type: 'refresh',
|
|
100
|
+
};
|
|
101
|
+
return signJwt(payload, secret);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Get token expiry times
|
|
106
|
+
*/
|
|
107
|
+
export function getTokenExpiry(): { accessToken: number; refreshToken: number } {
|
|
108
|
+
return {
|
|
109
|
+
accessToken: ACCESS_TOKEN_EXPIRY,
|
|
110
|
+
refreshToken: REFRESH_TOKEN_EXPIRY,
|
|
111
|
+
};
|
|
112
|
+
}
|