authkit-js
Version:
Express auth toolkit (JWT, Sessions with Redis, Google/GitHub OAuth) in JavaScript
91 lines (82 loc) • 4.08 kB
JavaScript
const { issueTokens, verifyToken } = require('../utils/jwt');
const { cookieOptions } = require('../utils/cookies');
const { Errors } = require('../utils/errors');
class JWTStrategy {
constructor(cfg) {
if (!cfg || !cfg.secret) throw new Error('JWTStrategy: secret required');
this.secret = cfg.secret;
this.opts = cfg.opts || {};
this.cookieCfg = Object.assign({ names: { access: 'atk', refresh: 'rtk' } }, cfg.cookie || {});
this.deserialize = cfg.deserializeUser || (async (payload) => ({ id: payload.sub }));
// Optional refresh token store (Map-like).
// API expected: get(userId) => storedToken | null, set(userId, token, ttlSec?), del(userId)
this.refreshStore = cfg.refreshStore || null;
}
async login(res, user) {
const tokens = issueTokens(user, this.secret, this.opts);
const c = cookieOptions(this.cookieCfg);
const names = (this.cookieCfg.names || { access: 'atk', refresh: 'rtk' });
const atMax = (this.opts.accessTokenTtlSec || 900) * 1000;
const rtMax = (this.opts.refreshTokenTtlSec || 604800) * 1000;
res.cookie(names.access, tokens.accessToken, { ...c, maxAge: atMax });
res.cookie(names.refresh, tokens.refreshToken, { ...c, maxAge: rtMax });
// Persist refresh token if store is present (single active token per user)
if (this.refreshStore && user && user.id != null) {
const ttlSec = this.opts.refreshTokenTtlSec || 604800;
await this.refreshStore.set(String(user.id), tokens.refreshToken, ttlSec);
}
return tokens;
}
async authenticate(req) {
const names = (this.cookieCfg.names || { access: 'atk', refresh: 'rtk' });
const hdr = req.headers && (req.headers.authorization || req.headers.Authorization);
const bearer = (typeof hdr === 'string' && hdr.startsWith('Bearer ')) ? hdr.slice(7) : undefined;
const token = bearer || (req.cookies && req.cookies[names.access]);
if (!token) return null;
try {
const payload = verifyToken(token, this.secret, this.opts);
const user = await this.deserialize(payload);
if (!user) return null;
return { user, token };
} catch (_) { return null; }
}
async refresh(req, res) {
const names = (this.cookieCfg.names || { access: 'atk', refresh: 'rtk' });
const rtk = req.cookies && req.cookies[names.refresh];
if (!rtk) throw Errors.BAD_REQUEST('Missing refresh token');
let payload;
try { payload = verifyToken(rtk, this.secret, this.opts); }
catch { throw Errors.TOKEN_INVALID(); }
const user = await this.deserialize(payload);
if (!user) throw Errors.UNAUTHORIZED('User not found');
// Reuse detection: if store exists, the incoming token must match the stored one
if (this.refreshStore) {
const stored = await this.refreshStore.get(String(user.id));
if (!stored) {
// No stored token means logout/rotation elsewhere
throw Errors.TOKEN_REUSE('Refresh token not recognized');
}
if (stored !== rtk) {
// Token reuse attempt: clear stored token and reject
await this.refreshStore.del(String(user.id));
throw Errors.TOKEN_REUSE();
}
}
const tokens = issueTokens(user, this.secret, this.opts);
const c = cookieOptions(this.cookieCfg);
res.cookie(names.access, tokens.accessToken, { ...c, maxAge: (this.opts.accessTokenTtlSec || 900) * 1000 });
res.cookie(names.refresh, tokens.refreshToken, { ...c, maxAge: (this.opts.refreshTokenTtlSec || 604800) * 1000 });
// Rotate stored token on successful refresh
if (this.refreshStore && user && user.id != null) {
const ttlSec = this.opts.refreshTokenTtlSec || 604800;
await this.refreshStore.set(String(user.id), tokens.refreshToken, ttlSec);
}
return tokens;
}
async logout(res) {
const names = (this.cookieCfg.names || { access: 'atk', refresh: 'rtk' });
res.clearCookie(names.access);
res.clearCookie(names.refresh);
}
}
module.exports = { JWTStrategy };