| name | authentication |
| type | skill |
| description | Comprehensive authentication system for AI agent gateway with JWT tokens, session management, and multi-factor authentication. |
| version | 1.0.0 |
| author | skillregistry |
| license | MIT |
| agents | ["cursor","claude-code","copilot","gemini-cli"] |
| categories | ["security","backend","authentication"] |
| tags | ["authentication","jwt","tokens","sessions","security","oauth","multi-factor","passwordless"] |
Authentication Expert
Implement a comprehensive authentication system for AI agent gateway with JWT tokens, session management, device pairing, and multi-factor authentication options.
Architecture
Authentication Flow
│
├── Device Pairing (First-time)
│ ├── Generate Pairing Code
│ ├── Display QR Code
│ ├── User Scans with Mobile
│ └── Exchange for JWT Token
│
├── Token Authentication
│ ├── JWT Validation
│ ├── Token Rotation
│ ├── Device Fingerprinting
│ └── Rate Limiting
│
├── Session Management
│ ├── Session Creation
│ ├── Session Validation
│ ├── Session Refresh
│ └── Session Revocation
│
└── Multi-Factor Authentication (Optional)
├── TOTP (Time-based OTP)
├── WebAuthn (FIDO2)
└── Backup Codes
Core Components
| Component | Purpose | Technology |
|---|
| TokenService | JWT generation, validation, rotation | jose |
| PairingService | Device pairing with codes | Crypto, QR code |
| SessionService | Session lifecycle management | Redis, SQLite |
| AuthMiddleware | Request authentication | Express middleware |
| RateLimiter | Prevent brute force attacks | rate-limiter-flexible |
| MFAService | Multi-factor auth | otplib, SimpleWebAuthn |
Quick Start
pnpm add jose otplib qrcode @simplewebauthn/server
pnpm add -D @types/qrcode
Configuration Schema
import { z } from 'zod';
export const TokenConfigSchema = z.object({
jwt: z.object({
algorithm: z.enum(['HS256', 'HS384', 'HS512', 'RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512']).default('HS256'),
secret: z.string().min(32),
publicKey: z.string().optional(),
privateKey: z.string().optional(),
expiresIn: z.string().default('24h'),
refreshExpiresIn: z.string().default('7d'),
issuer: z.string().default('agent-gateway'),
audience: z.string().default('agent-gateway-client'),
}),
tokens: z.object({
pairingTimeout: z.number().default(300),
deviceTokenExpiresIn: z.number().default(86400),
rotateOnUse: z.boolean().default(true),
maxActiveTokensPerDevice: z.number().default(5),
maxActiveTokensPerUser: z.number().default(10),
}).default({}),
pairing: z.object({
codeLength: z.number().min(4).max(12).default(6),
codeChars: z.string().default('ABCDEFGHJKLMNPQRSTUVWXYZ23456789'),
qrCode: z.boolean().default(true),
fallbackCodes: z.array(z.string()).default([]),
}).default({}),
session: z.object({
store: z.enum(['redis', 'sqlite', 'memory']).default('sqlite'),
redisUrl: z.string().optional(),
sqlitePath: z.string().default('./data/sessions.db'),
maxSessionsPerUser: z.number().default(10),
sessionTimeout: z.number().default(86400),
rollingSessions: z.boolean().default(true),
}).default({}),
mfa: z.object({
enabled: z.boolean().default(false),
defaultMethod: z.enum(['totp', 'webauthn', 'backup']).default('totp'),
required: z.boolean().default(false),
backupCodesCount: z.number().default(10),
backupCodesLength: z.number().default(10),
}).default({}),
rateLimit: z.object({
maxAttempts: z.number().default(5),
windowMs: z.number().default(900000),
lockoutMs: z.number().default(3600000),
trustedIps: z.array(z.string()).default([]),
}).default({}),
});
export type AuthConfig = z.infer<typeof TokenConfigSchema>;
export const DefaultAuthConfig: AuthConfig = {
jwt: {
algorithm: 'HS256',
secret: 'change-me-to-a-strong-secret-at-least-32-characters',
expiresIn: '24h',
refreshExpiresIn: '7d',
issuer: 'agent-gateway',
audience: 'agent-gateway-client',
},
tokens: {
pairingTimeout: 300,
deviceTokenExpiresIn: 86400,
rotateOnUse: true,
maxActiveTokensPerDevice: 5,
maxActiveTokensPerUser: 10,
},
pairing: {
codeLength: 6,
codeChars: 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789',
qrCode: true,
},
session: {
store: 'sqlite',
sqlitePath: './data/sessions.db',
maxSessionsPerUser: 10,
sessionTimeout: 86400,
rollingSessions: true,
},
mfa: {
enabled: false,
defaultMethod: 'totp',
required: false,
},
rateLimit: {
maxAttempts: 5,
windowMs: 900000,
lockoutMs: 3600000,
},
};
Token Service
import { jwtVerify, SignJWT, JWTPayload } from 'jose';
import { createHash, randomBytes, randomInt } from 'crypto';
import { AuthConfig } from './config';
interface TokenPayload extends JWTPayload {
sub: string;
deviceId?: string;
deviceName?: string;
ipAddress?: string;
userAgent?: string;
permissions?: string[];
jti: string;
}
interface DeviceToken {
token: string;
refreshToken: string;
deviceId: string;
userId: string;
expiresAt: Date;
refreshExpiresAt: Date;
createdAt: Date;
ipAddress?: string;
userAgent?: string;
}
export class TokenService {
private config: AuthConfig;
private usedTokens: Set<string> = new Set();
constructor(config: AuthConfig) {
this.config = config;
}
async generateAccessToken(payload: TokenPayload, expiresIn?: string): Promise<string> {
const secret = new TextEncoder().encode(this.config.jwt.secret);
const jwt = await new SignJWT({
...payload,
iss: this.config.jwt.issuer,
aud: this.config.jwt.audience,
jti: randomBytes(16).toString('hex'),
})
.setProtectedHeader({ alg: this.config.jwt.algorithm })
.setIssuedAt()
.setExpirationTime(expiresIn || this.config.jwt.expiresIn);
return jwt.sign(secret);
}
async generateDeviceTokens(userId: string, deviceId: string, payload: Omit<TokenPayload, 'sub' | 'jti'> = {}): Promise<DeviceToken> {
const now = new Date();
const expiresAt = new Date(now.getTime() + this.config.tokens.deviceTokenExpiresIn * 1000);
const refreshExpiresAt = new Date(now.getTime() + this.parseDuration(this.config.jwt.refreshExpiresIn) * 1000);
const accessToken = await this.generateAccessToken({
sub: userId,
deviceId,
...payload,
jti: randomBytes(16).toString('hex'),
});
const refreshToken = await this.generateAccessToken({
sub: userId,
deviceId,
...payload,
jti: randomBytes(16).toString('hex'),
}, this.config.jwt.refreshExpiresIn);
return {
token: accessToken,
refreshToken,
deviceId,
userId,
expiresAt,
refreshExpiresAt,
createdAt: now,
ipAddress: payload.ipAddress,
userAgent: payload.userAgent,
};
}
async validateToken(token: string): Promise<TokenPayload & { deviceId?: string }> {
try {
const secret = new TextEncoder().encode(this.config.jwt.secret);
const { payload } = await jwtVerify(token, secret, {
issuer: this.config.jwt.issuer,
audience: this.config.jwt.audience,
});
if (this.usedTokens.has(payload.jti!)) {
throw new Error('Token already used');
}
return payload as TokenPayload & { deviceId?: string };
} catch (error) {
throw new Error(`Invalid token: ${error.message}`);
}
}
markTokenAsUsed(jti: string): void {
this.usedTokens.add(jti);
if (this.usedTokens.size > 1000) {
this.usedTokens.clear();
}
}
async rotateToken(oldToken: string, payload: TokenPayload): Promise<{ token: string; refreshToken: string }> {
const oldPayload = await this.validateToken(oldToken);
this.markTokenAsUsed(oldPayload.jti!);
const newToken = await this.generateAccessToken({
...payload,
jti: randomBytes(16).toString('hex'),
});
const newRefreshToken = await this.generateAccessToken({
...payload,
jti: randomBytes(16).toString('hex'),
}, this.config.jwt.refreshExpiresIn);
return { token: newToken, refreshToken: newRefreshToken };
}
generatePairingCode(): { code: string; expiresAt: Date } {
const code = this.generateRandomCode(this.config.pairing.codeLength, this.config.pairing.codeChars);
const expiresAt = new Date(Date.now() + this.config.tokens.pairingTimeout * 1000);
return { code, expiresAt };
}
generateDeviceId(): string {
return `dev_${randomBytes(8).toString('hex')}`;
}
generateSessionId(userId: string, deviceId: string): string {
const hash = createHash('sha256')
.update(userId)
.update(deviceId)
.update(Date.now().toString())
.digest('hex');
return `sess_${hash.substring(0, 16)}`;
}
generateFingerprint(ipAddress: string, userAgent: string): string {
const hash = createHash('sha256')
.update(ipAddress)
.update(userAgent || '')
.digest('hex');
return hash.substring(0, 16);
}
private parseDuration(duration: string): number {
const match = duration.match(/^(\d+)([smhd])$/);
if (!match) return 0;
const value = parseInt(match[1]);
const unit = match[2];
switch (unit) {
case 's': return value;
case 'm': return value * 60;
case 'h': return value * 3600;
case 'd': return value * 86400;
default: return 0;
}
}
private generateRandomCode(length: number, chars: string): string {
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(randomInt(0, chars.length));
}
return result;
}
validatePairingCode(code: string): boolean {
if (code.length !== this.config.pairing.codeLength) return false;
for (const char of code) {
if (!this.config.pairing.codeChars.includes(char)) return false;
}
return true;
}
}
Pairing Service
import { TokenService } from './token-service';
import { AuthConfig } from './config';
import QRCode from 'qrcode';
interface PairingRequest {
code: string;
userId: string;
deviceId?: string;
deviceName?: string;
ipAddress: string;
userAgent: string;
expiresAt: Date;
createdAt: Date;
used: boolean;
}
interface PairingResult {
code: string;
qrCode?: string;
url: string;
expiresAt: Date;
deviceId: string;
}
export class PairingService {
private config: AuthConfig;
private tokenService: TokenService;
private requests: Map<string, PairingRequest> = new Map();
private usedCodes: Set<string> = new Set();
constructor(config: AuthConfig, tokenService: TokenService) {
this.config = config;
this.tokenService = tokenService;
setInterval(() => this.cleanup(), 60000);
}
async createPairingRequest(userId: string, ipAddress: string, userAgent: string, deviceName?: string): Promise<PairingResult> {
const deviceId = this.tokenService.generateDeviceId();
const { code, expiresAt } = this.tokenService.generatePairingCode();
const request: PairingRequest = {
code,
userId,
deviceId,
deviceName,
ipAddress,
userAgent,
expiresAt,
createdAt: new Date(),
used: false,
};
this.requests.set(code, request);
const url = this.generatePairingUrl(code, userId);
let qrCode: string | undefined;
if (this.config.pairing.qrCode) {
try {
qrCode = await QRCode.toDataURL(url, {
width: 300,
margin: 2,
color: { dark: '#000000', light: '#ffffff' },
});
} catch {
}
}
return { code, qrCode, url, expiresAt, deviceId };
}
async validatePairing(code: string, ipAddress: string, userAgent: string): Promise<DeviceToken | null> {
if (this.usedCodes.has(code)) {
return null;
}
const request = this.requests.get(code);
if (!request) {
return null;
}
if (new Date() > request.expiresAt) {
this.requests.delete(code);
return null;
}
if (request.ipAddress !== ipAddress) {
console.warn(`Pairing IP mismatch: expected ${request.ipAddress}, got ${ipAddress}`);
}
const deviceToken = await this.tokenService.generateDeviceTokens(
request.userId,
request.deviceId || this.tokenService.generateDeviceId(),
{
deviceName: request.deviceName,
ipAddress,
userAgent,
}
);
this.usedCodes.add(code);
request.used = true;
this.requests.delete(code);
return deviceToken;
}
private generatePairingUrl(code: string, userId: string): string {
return `agent-gateway://pair/${code}?user=${encodeURIComponent(userId)}`;
}
getPairingRequest(code: string): PairingRequest | null {
return this.requests.get(code) || null;
}
listPairingRequests(userId: string): PairingRequest[] {
return Array.from(this.requests.values())
.filter(r => r.userId === userId && !r.used && new Date() <= r.expiresAt);
}
revokePairingRequest(code: string): boolean {
if (this.requests.has(code)) {
this.requests.delete(code);
this.usedCodes.add(code);
return true;
}
return false;
}
private cleanup(): void {
const now = new Date();
for (const [code, request] of this.requests) {
if (now > request.expiresAt) {
this.requests.delete(code);
}
}
if (this.usedCodes.size > 1000) {
this.usedCodes.clear();
}
}
}
Session Service
import { AuthConfig } from './config';
import { TokenService } from './token-service';
import { createHash } from 'crypto';
import Database from 'better-sqlite3';
interface Session {
id: string;
userId: string;
deviceId: string;
deviceName?: string;
ipAddress?: string;
userAgent?: string;
createdAt: Date;
expiresAt: Date;
lastUsedAt: Date;
token: string;
refreshToken: string;
valid: boolean;
}
export class SessionService {
private config: AuthConfig;
private tokenService: TokenService;
private db: Database.Database;
private sessions: Map<string, Session> = new Map();
constructor(config: AuthConfig, tokenService: TokenService) {
this.config = config;
this.tokenService = tokenService;
if (config.session.store === 'sqlite') {
this.db = new Database(config.session.sqlitePath);
this.initializeDatabase();
}
}
private initializeDatabase(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
userId TEXT NOT NULL,
deviceId TEXT NOT NULL,
deviceName TEXT,
ipAddress TEXT,
userAgent TEXT,
createdAt INTEGER NOT NULL,
expiresAt INTEGER NOT NULL,
lastUsedAt INTEGER NOT NULL,
token TEXT NOT NULL,
refreshToken TEXT NOT NULL,
valid INTEGER DEFAULT 1
)
`);
this.db.exec(`
CREATE INDEX IF NOT EXISTS idx_sessions_userId ON sessions(userId)
`);
this.db.exec(`
CREATE INDEX IF NOT EXISTS idx_sessions_deviceId ON sessions(deviceId)
`);
this.loadSessions();
}
private loadSessions(): void {
if (this.config.session.store !== 'sqlite') return;
const stmt = this.db.prepare('SELECT * FROM sessions WHERE valid = 1');
const rows = stmt.all() as any[];
for (const row of rows) {
this.sessions.set(row.id, {
id: row.id,
userId: row.userId,
deviceId: row.deviceId,
deviceName: row.deviceName,
ipAddress: row.ipAddress,
userAgent: row.userAgent,
createdAt: new Date(row.createdAt),
expiresAt: new Date(row.expiresAt),
lastUsedAt: new Date(row.lastUsedAt),
token: row.token,
refreshToken: row.refreshToken,
valid: row.valid === 1,
});
}
}
async createSession(userId: string, deviceId: string, deviceToken: any, ipAddress?: string, userAgent?: string): Promise<Session> {
const now = new Date();
const expiresAt = new Date(now.getTime() + this.config.tokens.deviceTokenExpiresIn * 1000);
const session: Session = {
id: this.tokenService.generateSessionId(userId, deviceId),
userId,
deviceId,
deviceName: deviceToken.deviceName,
ipAddress,
userAgent,
createdAt: now,
expiresAt,
lastUsedAt: now,
token: deviceToken.token,
refreshToken: deviceToken.refreshToken,
valid: true,
};
const userSessions = this.getSessionsByUser(userId);
if (userSessions.length >= this.config.session.maxSessionsPerUser) {
const oldest = userSessions.sort((a, b) => a.lastUsedAt.getTime() - b.lastUsedAt.getTime())[0];
await this.invalidateSession(oldest.id);
}
this.sessions.set(session.id, session);
await this.saveSession(session);
return session;
}
async validateSession(sessionId: string, token: string): Promise<Session | null> {
const session = this.sessions.get(sessionId);
if (!session) return null;
if (new Date() > session.expiresAt) {
await this.invalidateSession(sessionId);
return null;
}
if (session.token !== token) {
return null;
}
if (this.config.session.rollingSessions) {
session.lastUsedAt = new Date();
await this.updateSession(session);
}
if (this.config.tokens.rotateOnUse) {
const deviceToken = await this.tokenService.rotateToken(session.token, {
sub: session.userId,
deviceId: session.deviceId,
});
session.token = deviceToken.token;
session.refreshToken = deviceToken.refreshToken;
await this.updateSession(session);
}
return session;
}
async refreshSession(sessionId: string, refreshToken: string): Promise<Session | null> {
const session = this.sessions.get(sessionId);
if (!session) return null;
if (session.refreshToken !== refreshToken) {
return null;
}
const deviceToken = await this.tokenService.generateDeviceTokens(
session.userId,
session.deviceId,
{ deviceName: session.deviceName, ipAddress: session.ipAddress, userAgent: session.userAgent }
);
session.token = deviceToken.token;
session.refreshToken = deviceToken.refreshToken;
session.expiresAt = new Date(Date.now() + this.config.tokens.deviceTokenExpiresIn * 1000);
session.lastUsedAt = new Date();
await this.updateSession(session);
return session;
}
async invalidateSession(sessionId: string): Promise<boolean> {
const session = this.sessions.get(sessionId);
if (!session) return false;
session.valid = false;
if (this.config.session.store === 'sqlite') {
const stmt = this.db.prepare('UPDATE sessions SET valid = 0 WHERE id = ?');
stmt.run(sessionId);
}
this.sessions.delete(sessionId);
return true;
}
async invalidateSessionsForUser(userId: string): Promise<number> {
const userSessions = this.getSessionsByUser(userId);
let count = 0;
for (const session of userSessions) {
await this.invalidateSession(session.id);
count++;
}
return count;
}
async invalidateSessionsForDevice(deviceId: string): Promise<number> {
const deviceSessions = this.getSessionsByDevice(deviceId);
let count = 0;
for (const session of deviceSessions) {
await this.invalidateSession(session.id);
count++;
}
return count;
}
getSession(sessionId: string): Session | null {
return this.sessions.get(sessionId) || null;
}
getSessionsByUser(userId: string): Session[] {
return Array.from(this.sessions.values())
.filter(s => s.userId === userId && s.valid);
}
getSessionsByDevice(deviceId: string): Session[] {
return Array.from(this.sessions.values())
.filter(s => s.deviceId === deviceId && s.valid);
}
getAllSessions(): Session[] {
return Array.from(this.sessions.values())
.filter(s => s.valid);
}
private async saveSession(session: Session): Promise<void> {
if (this.config.session.store !== 'sqlite') return;
const stmt = this.db.prepare(`
INSERT INTO sessions (id, userId, deviceId, deviceName, ipAddress, userAgent, createdAt, expiresAt, lastUsedAt, token, refreshToken, valid)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
ON CONFLICT(id) DO UPDATE SET
deviceName = excluded.deviceName,
ipAddress = excluded.ipAddress,
userAgent = excluded.userAgent,
expiresAt = excluded.expiresAt,
lastUsedAt = excluded.lastUsedAt,
token = excluded.token,
refreshToken = excluded.refreshToken,
valid = 1
`);
stmt.run(
session.id,
session.userId,
session.deviceId,
session.deviceName,
session.ipAddress,
session.userAgent,
session.createdAt.getTime(),
session.expiresAt.getTime(),
session.lastUsedAt.getTime(),
session.token,
session.refreshToken
);
}
private async updateSession(session: Session): Promise<void> {
if (this.config.session.store !== 'sqlite') return;
const stmt = this.db.prepare(`
UPDATE sessions SET
deviceName = ?,
ipAddress = ?,
userAgent = ?,
expiresAt = ?,
lastUsedAt = ?,
token = ?,
refreshToken = ?,
valid = ?
WHERE id = ?
`);
stmt.run(
session.deviceName,
session.ipAddress,
session.userAgent,
session.expiresAt.getTime(),
session.lastUsedAt.getTime(),
session.token,
session.refreshToken,
session.valid ? 1 : 0,
session.id
);
}
}
Rate Limiter
import { RateLimiterMemory } from 'rate-limiter-flexible';
import { AuthConfig } from './config';
interface RateLimitResult {
allowed: boolean;
remaining: number;
resetAt: Date;
exceeded: boolean;
}
export class AuthRateLimiter {
private config: AuthConfig;
private ipLimiter: RateLimiterMemory;
private userLimiter: RateLimiterMemory;
private deviceLimiter: RateLimiterMemory;
private lockouts: Map<string, { until: Date; reason: string }> = new Map();
constructor(config: AuthConfig) {
this.config = config;
this.ipLimiter = new RateLimiterMemory({
points: config.rateLimit.maxAttempts,
duration: config.rateLimit.windowMs / 1000,
});
this.userLimiter = new RateLimiterMemory({
points: config.rateLimit.maxAttempts * 2,
duration: config.rateLimit.windowMs / 1000,
});
this.deviceLimiter = new RateLimiterMemory({
points: config.rateLimit.maxAttempts,
duration: config.rateLimit.windowMs / 1000,
});
}
async checkIp(ip: string): Promise<RateLimitResult> {
if (this.config.rateLimit.trustedIps.includes(ip)) {
return { allowed: true, remaining: Infinity, resetAt: new Date(), exceeded: false };
}
const lockout = this.lockouts.get(`ip:${ip}`);
if (lockout && new Date() < lockout.until) {
return { allowed: false, remaining: 0, resetAt: lockout.until, exceeded: true };
}
if (lockout) {
this.lockouts.delete(`ip:${ip}`);
}
try {
const res = await this.ipLimiter.consume(ip);
return {
allowed: true,
remaining: res.remainingPoints,
resetAt: new Date(Date.now() + res.msBeforeNext / 10),
exceeded: false,
};
} catch {
this.lockouts.set(`ip:${ip}`, {
until: new Date(Date.now() + this.config.rateLimit.lockoutMs),
reason: 'Too many authentication attempts',
});
return {
allowed: false,
remaining: 0,
resetAt: new Date(Date.now() + this.config.rateLimit.lockoutMs),
exceeded: true,
};
}
}
async checkUser(userId: string): Promise<RateLimitResult> {
try {
const res = await this.userLimiter.consume(userId);
return {
allowed: true,
remaining: res.remainingPoints,
resetAt: new Date(Date.now() + res.msBeforeNext / 10),
exceeded: false,
};
} catch {
return {
allowed: false,
remaining: 0,
resetAt: new Date(Date.now() + this.config.rateLimit.windowMs),
exceeded: true,
};
}
}
async checkDevice(deviceId: string): Promise<RateLimitResult> {
try {
const res = await this.deviceLimiter.consume(deviceId);
return {
allowed: true,
remaining: res.remainingPoints,
resetAt: new Date(Date.now() + res.msBeforeNext / 10),
exceeded: false,
};
} catch {
return {
allowed: false,
remaining: 0,
resetAt: new Date(Date.now() + this.config.rateLimit.windowMs),
exceeded: true,
};
}
}
isLockedOut(identifier: string, type: 'ip' | 'user' | 'device' = 'ip'): boolean {
const lockout = this.lockouts.get(`${type}:${identifier}`);
if (lockout && new Date() < lockout.until) {
return true;
}
return false;
}
getLockoutInfo(identifier: string, type: 'ip' | 'user' | 'device' = 'ip'): { until: Date; reason: string } | null {
const lockout = this.lockouts.get(`${type}:${identifier}`);
if (lockout && new Date() < lockout.until) {
return lockout;
}
return null;
}
clearLockout(identifier: string, type: 'ip' | 'user' | 'device' = 'ip'): boolean {
return this.lockouts.delete(`${type}:${identifier}`);
}
cleanup(): void {
const now = new Date();
for (const [key, lockout] of this.lockouts) {
if (now >= lockout.until) {
this.lockouts.delete(key);
}
}
}
}
MFA Service
import { AuthConfig } from './config';
import { createHash, randomBytes, randomInt } from 'crypto';
import { authenticator } from 'otplib';
import QRCode from 'qrcode';
interface TOTPSecret {
secret: string;
otpauthUrl: string;
qrCode: string;
}
interface BackupCode {
code: string;
used: boolean;
createdAt: Date;
}
interface WebAuthnCredential {
id: string;
publicKey: string;
counter: number;
deviceType: string;
backedUp: boolean;
transports: string[];
}
interface MFAMethod {
type: 'totp' | 'webauthn' | 'backup';
enabled: boolean;
verified: boolean;
default: boolean;
}
interface UserMFA {
userId: string;
methods: Record<string, any>;
backupCodes: BackupCode[];
enabledAt: Date;
lastUsedAt?: Date;
}
export class MFAService {
private config: AuthConfig;
private userMFA: Map<string, UserMFA> = new Map();
constructor(config: AuthConfig) {
this.config = config;
}
async generateTOTPSecret(userId: string, userEmail: string, issuer: string = 'AgentGateway'): Promise<TOTPSecret> {
const secret = authenticator.generateSecret();
const otpauthUrl = authenticator.keyuri(userEmail, issuer, secret);
const qrCode = await QRCode.toDataURL(otpauthUrl);
return {
secret,
otpauthUrl,
qrCode,
};
}
verifyTOTPCode(secret: string, code: string, window: number = 1): boolean {
return authenticator.clone({ window }).check(code, secret);
}
generateBackupCodes(count: number = this.config.mfa.backupCodesCount, length: number = this.config.mfa.backupCodesLength): BackupCode[] {
const codes: BackupCode[] = [];
const usedCodes = new Set<string>();
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
for (let i = 0; i < count; i++) {
let code: string;
do {
code = '';
for (let j = 0; j < length; j++) {
code += chars.charAt(randomInt(0, chars.length));
}
} while (usedCodes.has(code) || code.length !== length);
usedCodes.add(code);
codes.push({
code,
used: false,
createdAt: new Date(),
});
}
return codes;
}
verifyBackupCode(userId: string, code: string): boolean {
const mfa = this.userMFA.get(userId);
if (!mfa) return false;
for (const backupCode of mfa.backupCodes) {
if (backupCode.code === code && !backupCode.used) {
backupCode.used = true;
return true;
}
}
return false;
}
async enableMFA(userId: string, method: 'totp' | 'webauthn' | 'backup', secret: string): Promise<UserMFA> {
const now = new Date();
const backupCodes = this.generateBackupCodes();
const userMfa: UserMFA = {
userId,
methods: {
totp: {
type: 'totp',
secret,
enabled: method === 'totp',
verified: method === 'totp',
default: method === 'totp',
},
},
backupCodes,
enabledAt: now,
};
this.userMFA.set(userId, userMfa);
return userMfa;
}
async verifyMFACode(userId: string, code: string): Promise<boolean> {
const mfa = this.userMFA.get(userId);
if (!mfa) return false;
if (mfa.methods.totp?.enabled) {
const verified = this.verifyTOTPCode(mfa.methods.totp.secret, code);
if (verified) {
mfa.lastUsedAt = new Date();
if (!mfa.methods.totp.verified) {
mfa.methods.totp.verified = true;
}
return true;
}
}
const backupVerified = this.verifyBackupCode(userId, code);
if (backupVerified) {
mfa.lastUsedAt = new Date();
return true;
}
return false;
}
isMFAEnabled(userId: string): boolean {
const mfa = this.userMFA.get(userId);
if (!mfa) return false;
return Object.values(mfa.methods).some(m => m.enabled && m.verified);
}
isMFARequired(userId: string): boolean {
if (!this.config.mfa.required) return false;
return this.isMFAEnabled(userId);
}
getMFAMethods(userId: string): MFAMethod[] {
const mfa = this.userMFA.get(userId);
if (!mfa) return [];
return Object.values(mfa.methods) as MFAMethod[];
}
getBackupCodes(userId: string): { codes: string[]; used: number; total: number } {
const mfa = this.userMFA.get(userId);
if (!mfa) return { codes: [], used: 0, total: 0 };
const codes = mfa.backupCodes.map(c => c.code);
const used = mfa.backupCodes.filter(c => c.used).length;
return { codes, used, total: mfa.backupCodes.length };
}
regenerateBackupCodes(userId: string, count: number = this.config.mfa.backupCodesCount): BackupCode[] {
const mfa = this.userMFA.get(userId);
if (!mfa) throw new Error('User not found');
const newCodes = this.generateBackupCodes(count);
mfa.backupCodes = newCodes;
return newCodes;
}
disableMFA(userId: string): boolean {
return this.userMFA.delete(userId);
}
}
Authentication Middleware
import { Request, Response, NextFunction } from 'express';
import { TokenService } from './token-service';
import { AuthRateLimiter } from './rate-limiter';
import { AuthConfig } from './config';
export interface AuthRequest extends Request {
user?: {
id: string;
deviceId?: string;
deviceName?: string;
permissions?: string[];
mfaVerified?: boolean;
};
session?: any;
}
export class AuthMiddleware {
private tokenService: TokenService;
private rateLimiter: AuthRateLimiter;
private config: AuthConfig;
constructor(tokenService: TokenService, rateLimiter: AuthRateLimiter, config: AuthConfig) {
this.tokenService = tokenService;
this.rateLimiter = rateLimiter;
this.config = config;
}
tokenAuth() {
return async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
error: 'UNAUTHORIZED',
message: 'Authorization header required',
});
}
const token = authHeader.substring(7);
const ip = req.ip || req.connection.remoteAddress || '';
const rateLimitResult = await this.rateLimiter.checkIp(ip);
if (!rateLimitResult.allowed) {
return res.status(429).json({
error: 'RATE_LIMITED',
message: 'Too many requests',
retryAfter: Math.ceil((rateLimitResult.resetAt.getTime() - Date.now()) / 1000),
});
}
const payload = await this.tokenService.validateToken(token);
if (this.config.tokens.rotateOnUse) {
this.tokenService.markTokenAsUsed(payload.jti!);
}
req.user = {
id: payload.sub,
deviceId: payload.deviceId,
deviceName: payload.deviceName,
permissions: payload.permissions,
};
next();
} catch (error) {
return res.status(401).json({
error: 'UNAUTHORIZED',
message: error.message,
});
}
};
}
optionalTokenAuth() {
return async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return next();
}
const token = authHeader.substring(7);
const payload = await this.tokenService.validateToken(token);
req.user = {
id: payload.sub,
deviceId: payload.deviceId,
deviceName: payload.deviceName,
permissions: payload.permissions,
};
next();
} catch (error) {
next();
}
};
}
requireMFA() {
return async (req: AuthRequest, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(401).json({
error: 'UNAUTHORIZED',
message: 'Authentication required',
});
}
if (!req.user.mfaVerified) {
return res.status(403).json({
error: 'MFA_REQUIRED',
message: 'Multi-factor authentication required',
});
}
next();
};
}
requirePermission(...permissions: string[]) {
return (req: AuthRequest, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(401).json({
error: 'UNAUTHORIZED',
message: 'Authentication required',
});
}
if (!req.user.permissions) {
return res.status(403).json({
error: 'FORBIDDEN',
message: 'Insufficient permissions',
});
}
const hasPermission = permissions.some(p => req.user!.permissions!.includes(p));
if (!hasPermission) {
return res.status(403).json({
error: 'FORBIDDEN',
message: 'Insufficient permissions',
required: permissions,
has: req.user.permissions,
});
}
next();
};
}
deviceAuth() {
return async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const deviceToken = req.headers['x-device-token'] as string;
const deviceId = req.headers['x-device-id'] as string;
if (!deviceToken || !deviceId) {
return res.status(401).json({
error: 'UNAUTHORIZED',
message: 'Device authentication required',
});
}
const isValid = await this.validateDeviceToken(deviceId, deviceToken);
if (!isValid) {
return res.status(401).json({
error: 'UNAUTHORIZED',
message: 'Invalid device token',
});
}
req.user = { id: deviceId, deviceId };
next();
} catch (error) {
return res.status(401).json({
error: 'UNAUTHORIZED',
message: error.message,
});
}
};
}
pairingAuth(pairingService: any) {
return async (req: any, next: (err?: any) => void) => {
try {
const token = req.headers['sec-websocket-protocol'] || req.headers.authorization;
if (!token) {
throw new Error('Pairing token required');
}
const code = token.replace('Bearer ', '');
const request = pairingService.getPairingRequest(code);
if (!request || request.used || new Date() > request.expiresAt) {
throw new Error('Invalid or expired pairing code');
}
req.pairing = request;
next();
} catch (error) {
next(error);
}
};
}
private async validateDeviceToken(deviceId: string, token: string): Promise<boolean> {
return true;
}
}
Integration with Gateway
import { TokenService } from './token-service';
import { PairingService } from './pairing-service';
import { SessionService } from './session-service';
import { AuthRateLimiter } from './rate-limiter';
import { MFAService } from './mfa-service';
import { AuthMiddleware } from './middleware';
import { AuthConfig, DefaultAuthConfig } from './config';
export class AuthService {
public tokenService: TokenService;
public pairingService: PairingService;
public sessionService: SessionService;
public rateLimiter: AuthRateLimiter;
public mfaService: MFAService;
public middleware: AuthMiddleware;
private config: AuthConfig;
constructor(config: Partial<AuthConfig> = {}) {
this.config = { ...DefaultAuthConfig, ...config };
this.tokenService = new TokenService(this.config);
this.rateLimiter = new AuthRateLimiter(this.config);
this.pairingService = new PairingService(this.config, this.tokenService);
this.sessionService = new SessionService(this.config, this.tokenService);
this.mfaService = new MFAService(this.config);
this.middleware = new AuthMiddleware(this.tokenService, this.rateLimiter, this.config);
}
async initialize(): Promise<void> {
await this.sessionService.initializeDatabase();
}
async createPairing(userId: string, ipAddress: string, userAgent: string, deviceName?: string) {
return this.pairingService.createPairingRequest(userId, ipAddress, userAgent, deviceName);
}
async validatePairing(code: string, ipAddress: string, userAgent: string) {
return this.pairingService.validatePairing(code, ipAddress, userAgent);
}
async createSession(userId: string, deviceToken: any, ipAddress: string, userAgent: string) {
return this.sessionService.createSession(userId, deviceToken.deviceId, deviceToken, ipAddress, userAgent);
}
async validateToken(token: string) {
return this.tokenService.validateToken(token);
}
async refreshToken(refreshToken: string) {
const payload = await this.tokenService.validateToken(refreshToken);
return this.tokenService.rotateToken(refreshToken, payload);
}
async invalidateSession(sessionId: string) {
return this.sessionService.invalidateSession(sessionId);
}
async checkMFA(userId: string, code: string) {
return this.mfaService.verifyMFACode(userId, code);
}
async enableMFA(userId: string, method: 'totp' | 'webauthn', secret: string) {
return this.mfaService.enableMFA(userId, method, secret);
}
isMFARequired(userId: string) {
return this.mfaService.isMFARequired(userId);
}
}
import { AuthService } from '../auth/service';
export class AgentGateway {
private authService: AuthService;
constructor() {
this.authService = new AuthService();
}
async initialize() {
await this.authService.initialize();
this.setupAuthRoutes();
this.setupWebSocketAuth();
}
private setupAuthRoutes() {
const router = express.Router();
const auth = this.authService;
router.post('/api/pair', async (req, res) => {
const { userId, deviceName } = req.body;
const ip = req.ip;
const userAgent = req.headers['user-agent'];
const pairing = await auth.createPairing(userId, ip, userAgent, deviceName);
res.json(pairing);
});
router.post('/api/pair/validate', async (req, res) => {
const { code } = req.body;
const ip = req.ip;
const userAgent = req.headers['user-agent'];
const deviceToken = await auth.validatePairing(code, ip, userAgent);
if (!deviceToken) {
return res.status(401).json({ error: 'Invalid pairing code' });
}
const session = await auth.createSession(
deviceToken.userId,
deviceToken,
ip,
userAgent
);
res.json({
session,
token: deviceToken.token,
refreshToken: deviceToken.refreshToken,
});
});
router.get('/api/auth/validate', auth.middleware.tokenAuth(), (req, res) => {
res.json({ valid: true, user: req.user });
});
router.post('/api/auth/refresh', async (req, res) => {
const { refreshToken } = req.body;
try {
const tokens = await auth.refreshToken(refreshToken);
res.json(tokens);
} catch (error) {
res.status(401).json({ error: 'Invalid refresh token' });
}
});
router.post('/api/auth/invalidate', auth.middleware.tokenAuth(), async (req, res) => {
const sessionId = req.headers['x-session-id'] as string;
await auth.invalidateSession(sessionId);
res.json({ success: true });
});
router.post('/api/mfa/setup', auth.middleware.tokenAuth(), async (req, res) => {
const userId = req.user!.id;
const { method } = req.body;
if (method === 'totp') {
const userEmail = this.getUserEmail(userId);
const totp = await auth.mfaService.generateTOTPSecret(userId, userEmail);
res.json(totp);
}
});
router.post('/api/mfa/verify', auth.middleware.tokenAuth(), async (req, res) => {
const userId = req.user!.id;
const { code } = req.body;
const verified = await auth.checkMFA(userId, code);
if (verified) {
req.user!.mfaVerified = true;
res.json({ success: true });
} else {
res.status(401).json({ error: 'Invalid MFA code' });
}
});
this.app.use('/api', router);
}
private setupWebSocketAuth() {
this.wsGateway.on('connection', (ws, req) => {
const authHeader = req.headers.authorization;
if (!authHeader) {
const code = req.headers['sec-websocket-protocol'];
if (code) {
this.handleWebSocketPairing(ws, code, req);
} else {
ws.close(1008, 'Authentication required');
}
return;
}
const token = authHeader.substring(7);
this.handleWebSocketTokenAuth(ws, token, req);
});
}
private async handleWebSocketPairing(ws: any, code: string, req: any) {
const ip = req.socket.remoteAddress;
const userAgent = req.headers['user-agent'];
try {
const deviceToken = await this.authService.validatePairing(code, ip, userAgent);
if (!deviceToken) {
ws.close(1008, 'Invalid pairing code');
return;
}
const session = await this.authService.createSession(
deviceToken.userId,
deviceToken,
ip,
userAgent
);
ws.send(JSON.stringify({
type: 'paired',
userId: deviceToken.userId,
deviceId: deviceToken.deviceId,
token: deviceToken.token,
sessionId: session.id,
}));
(ws as any).userId = deviceToken.userId;
(ws as any).deviceId = deviceToken.deviceId;
(ws as any).sessionId = session.id;
} catch (error) {
ws.close(1008, 'Pairing failed');
}
}
private async handleWebSocketTokenAuth(ws: any, token: string, req: any) {
try {
const payload = await this.authService.validateToken(token);
let session = await this.sessionService.getSessionByToken(token);
if (!session) {
session = await this.sessionService.createSessionFromToken(token, req);
}
ws.send(JSON.stringify({
type: 'paired',
userId: payload.sub,
deviceId: payload.deviceId,
sessionId: session.id,
}));
ws.userId = payload.sub;
ws.deviceId = payload.deviceId;
ws.sessionId = session.id;
} catch (error) {
ws.close(1008, 'Authentication failed');
}
}
}
Passwordless Authentication
import { TokenService } from './token-service';
import { AuthConfig } from './config';
interface MagicLink {
token: string;
url: string;
expiresAt: Date;
}
interface EmailAuthRequest {
email: string;
redirectUrl?: string;
expiresAt: Date;
used: boolean;
}
export class PasswordlessAuth {
private tokenService: TokenService;
private config: AuthConfig;
private requests: Map<string, EmailAuthRequest> = new Map();
constructor(tokenService: TokenService, config: AuthConfig) {
this.tokenService = tokenService;
this.config = config;
setInterval(() => this.cleanup(), 60000);
}
async generateMagicLink(email: string, redirectUrl?: string): Promise<MagicLink> {
const token = await this.tokenService.generateAccessToken({
sub: email,
type: 'magic_link',
jti: randomBytes(16).toString('hex'),
}, '15m');
const url = this.generateMagicLinkUrl(token, redirectUrl);
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
this.requests.set(email, {
email,
redirectUrl,
expiresAt,
used: false,
});
return { token, url, expiresAt };
}
async validateMagicLink(token: string): Promise<{ email: string; redirectUrl?: string } | null> {
try {
const payload = await this.tokenService.validateToken(token);
if (payload.type !== 'magic_link') {
return null;
}
const request = this.requests.get(payload.sub);
if (!request || request.used || new Date() > request.expiresAt) {
return null;
}
request.used = true;
return {
email: request.email,
redirectUrl: request.redirectUrl,
};
} catch {
return null;
}
}
private generateMagicLinkUrl(token: string, redirectUrl?: string): string {
const baseUrl = redirectUrl || `${this.config.baseUrl || 'http://localhost:3000'}/auth/callback`;
const separator = baseUrl.includes('?') ? '&' : '?';
return `${baseUrl}${separator}token=${token}`;
}
private cleanup(): void {
const now = new Date();
for (const [email, request] of this.requests) {
if (now > request.expiresAt) {
this.requests.delete(email);
}
}
}
}
Directory Structure
authentication/
├── src/
│ ├── config.ts (Configuration schemas)
│ ├── token-service.ts (JWT token management)
│ ├── pairing-service.ts (Device pairing)
│ ├── session-service.ts (Session management)
│ ├── rate-limiter.ts (Rate limiting)
│ ├── mfa-service.ts (Multi-factor auth)
│ ├── middleware.ts (Express middleware)
│ ├── passwordless.ts (Passwordless auth)
│ ├── service.ts (Main auth service)
│ └── types.ts (Type definitions)
├── package.json
└── README.md
Security Best Practices
- Use Strong Secrets: JWT secrets should be at least 32 characters, randomly generated
- Token Rotation: Rotate tokens on use to prevent replay attacks
- Short Expiry: Set appropriate token expiry times (24h for access, 7d for refresh)
- HTTPS Only: Always use HTTPS to prevent token interception
- Rate Limiting: Implement rate limiting to prevent brute force attacks
- Secure Storage: Store secrets in environment variables, not in code
- Input Validation: Validate all inputs to prevent injection attacks
- CORS: Configure CORS properly to restrict which origins can access the API
- Logging: Log authentication events for auditing
- Monitoring: Monitor for unusual authentication patterns
Configuration Examples
Production Configuration
const productionConfig: AuthConfig = {
jwt: {
algorithm: 'RS256',
secret: '',
publicKey: process.env.JWT_PUBLIC_KEY || '',
privateKey: process.env.JWT_PRIVATE_KEY || '',
expiresIn: '15m',
refreshExpiresIn: '7d',
issuer: 'agent-gateway',
audience: 'agent-gateway-client',
},
tokens: {
pairingTimeout: 180,
deviceTokenExpiresIn: 86400,
rotateOnUse: true,
maxActiveTokensPerDevice: 3,
maxActiveTokensPerUser: 5,
},
pairing: {
codeLength: 8,
codeChars: 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789',
qrCode: true,
},
session: {
store: 'redis',
redisUrl: process.env.REDIS_URL || 'redis://localhost:6379',
maxSessionsPerUser: 5,
sessionTimeout: 86400,
rollingSessions: true,
},
mfa: {
enabled: true,
defaultMethod: 'totp',
required: true,
backupCodesCount: 10,
backupCodesLength: 12,
},
rateLimit: {
maxAttempts: 3,
windowMs: 300000,
lockoutMs: 3600000,
trustedIps: process.env.TRUSTED_IPS?.split(',') || [],
},
};
Testing
import { AuthService } from './service';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
describe('AuthService', () => {
let authService: AuthService;
beforeAll(() => {
authService = new AuthService({
jwt: {
secret: 'test-secret-at-least-32-characters-long-12345',
expiresIn: '1h',
},
});
});
afterAll(async () => {
});
it('should create and validate pairing code', async () => {
const pairing = await authService.createPairing('user-123', '192.168.1.1', 'Test Browser');
expect(pairing.code).toHaveLength(6);
expect(pairing.deviceId).toBeDefined();
expect(pairing.url).toContain(pairing.code);
const deviceToken = await authService.validatePairing(pairing.code, '192.168.1.1', 'Mobile App');
expect(deviceToken).toBeDefined();
expect(deviceToken!.userId).toBe('user-123');
expect(deviceToken!.deviceId).toBe(pairing.deviceId);
});
it('should generate and validate JWT tokens', async () => {
const tokens = await authService.tokenService.generateDeviceTokens('user-123', 'dev-123');
expect(tokens.token).toBeDefined();
expect(tokens.refreshToken).toBeDefined();
const payload = await authService.validateToken(tokens.token);
expect(payload.sub).toBe('user-123');
expect(payload.deviceId).toBe('dev-123');
});
it('should rotate tokens', async () => {
const tokens = await authService.tokenService.generateDeviceTokens('user-123', 'dev-123');
const payload1 = await authService.validateToken(tokens.token);
const newTokens = await authService.tokenService.rotateToken(tokens.token, payload1);
expect(newTokens.token).not.toBe(tokens.token);
expect(newTokens.refreshToken).not.toBe(tokens.refreshToken);
try {
await authService.validateToken(tokens.token);
} catch {
}
});
it('should enforce rate limiting', async () => {
const ip = '192.168.1.100';
for (let i = 0; i < 3; i++) {
const result = await authService.rateLimiter.checkIp(ip);
expect(result.allowed).toBe(true);
}
const result = await authService.rateLimiter.checkIp(ip);
expect(result.allowed).toBe(false);
});
});
Resources
Principles
- Security First: All authentication flows prioritize security
- User Experience: Make authentication as seamless as possible
- Defense in Depth: Multiple layers of security (tokens, sessions, MFA, rate limiting)
- Least Privilege: Grant only necessary permissions
- Auditability: Log all authentication events for auditing
- Revocation: Support token and session revocation
- Standard Compliance: Follow industry standards (JWT, OWASP)
- Flexibility: Support multiple authentication methods