mikroauth
Version:
Dead-simple magic link authentication that is useful, lightweight, and uncluttered.
80 lines (74 loc) • 19.4 kB
JavaScript
import f from'node:crypto';import {URL}from'node:url';import {MikroConf,parsers}from'mikroconf';import {EventEmitter}from'node:events';import {MikroMail}from'mikromail';var g=()=>{let o=T(process.env.DEBUG)||false;return {auth:{jwtSecret:process.env.AUTH_JWT_SECRET||"your-jwt-secret",magicLinkExpirySeconds:900,jwtExpirySeconds:3600,refreshTokenExpirySeconds:604800,maxActiveSessions:3,appUrl:process.env.APP_URL||"http://localhost:3000",templates:null,debug:o},email:{emailSubject:"Your Secure Login Link",user:process.env.EMAIL_USER||"",host:process.env.EMAIL_HOST||"",password:process.env.EMAIL_PASSWORD||"",port:465,secure:true,maxRetries:2,debug:o},storage:{databaseDirectory:"mikroauth",encryptionKey:process.env.STORAGE_KEY||"",debug:o},server:{port:Number(process.env.PORT)||3e3,host:process.env.HOST||"0.0.0.0",useHttps:false,useHttp2:false,sslCert:"",sslKey:"",sslCa:"",rateLimit:{enabled:true,requestsPerMinute:100},allowedDomains:["*"],debug:o}}};function T(o){return o==="true"||o===true}var y=class{algorithm="HS256";secret="HS256";constructor(e){if(process.env.NODE_ENV==="production"&&(!e||e.length<32||e===g().auth.jwtSecret))throw new Error("Production environment requires a strong JWT secret (min 32 chars)");this.secret=e;}sign(e,t={}){let r={alg:this.algorithm,typ:"JWT"},i=Math.floor(Date.now()/1e3),s={...e,iat:i};t.exp!==void 0&&(s.exp=i+t.exp),t.notBefore!==void 0&&(s.nbf=i+t.notBefore),t.issuer&&(s.iss=t.issuer),t.audience&&(s.aud=t.audience),t.subject&&(s.sub=t.subject),t.jwtid&&(s.jti=t.jwtid);let a=this.base64UrlEncode(JSON.stringify(r)),d=this.base64UrlEncode(JSON.stringify(s)),l=`${a}.${d}`,c=this.createSignature(l);return `${l}.${c}`}verify(e,t={}){let r=this.decode(e);if(r.header.alg!==this.algorithm)throw new Error(`Invalid algorithm. Expected ${this.algorithm}, got ${r.header.alg}`);let[i,s]=e.split("."),a=`${i}.${s}`;if(this.createSignature(a)!==r.signature)throw new Error("Invalid signature");let l=r.payload,c=Math.floor(Date.now()/1e3),u=t.clockTolerance||0;if(l.exp!==void 0&&l.exp+u<c)throw new Error("Token expired");if(l.nbf!==void 0&&l.nbf-u>c)throw new Error("Token not yet valid");if(t.issuer&&l.iss!==t.issuer)throw new Error("Invalid issuer");if(t.audience&&l.aud!==t.audience)throw new Error("Invalid audience");if(t.subject&&l.sub!==t.subject)throw new Error("Invalid subject");return l}decode(e){let t=e.split(".");if(t.length!==3)throw new Error("Invalid token format");try{let[r,i,s]=t,a=JSON.parse(this.base64UrlDecode(r)),d=JSON.parse(this.base64UrlDecode(i));return {header:a,payload:d,signature:s}}catch{throw new Error("Failed to decode token")}}createSignature(e){let t=f.createHmac("sha256",this.secret).update(e).digest();return this.base64UrlEncode(t)}base64UrlEncode(e){let t;return typeof e=="string"?t=Buffer.from(e):t=e,t.toString("base64").replace(/=/g,"").replace(/\+/g,"-").replace(/\//g,"_")}base64UrlDecode(e){let t=e.replace(/-/g,"+").replace(/_/g,"/");switch(t.length%4){case 0:break;case 2:t+="==";break;case 3:t+="=";break;default:throw new Error("Invalid base64 string")}return Buffer.from(t,"base64").toString()}};var w=class{templates;constructor(e){e?this.templates=e:this.templates=L;}getText(e,t){return this.templates.textVersion(e,t).trim()}getHtml(e,t){return this.templates.htmlVersion(e,t).trim()}},L={textVersion:(o,e)=>`
Click this link to login: ${o}
Security Information:
- Expires in ${e} minutes
- Can only be used once
- Should only be used by you
If you didn't request this link, please ignore this email.
`,htmlVersion:(o,e)=>`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Your Login Link</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.container {
border: 1px solid #e1e1e1;
border-radius: 5px;
padding: 20px;
}
.button {
display: inline-block;
background-color: #4CAF50;
color: white;
text-decoration: none;
padding: 10px 20px;
border-radius: 5px;
margin: 20px 0;
}
.security-info {
background-color: #f8f8f8;
padding: 15px;
border-radius: 5px;
margin-top: 20px;
}
.footer {
margin-top: 20px;
font-size: 12px;
color: #888;
}
</style>
</head>
<body>
<div class="container">
<h2>Your Secure Login Link</h2>
<p>Click the button below to log in to your account:</p>
<a href="${o}" class="button">Login to Your Account</a>
<p>
Hello, this is a test email! Hall\xE5, MikroMail has international support for, among others, espa\xF1ol, fran\xE7ais, portugu\xEAs, \u4E2D\u6587, \u65E5\u672C\u8A9E, and \u0420\u0443\u0441\u0441\u043A\u0438\u0439!
</p>
<div class="security-info">
<h3>Security Information:</h3>
<ul>
<li>This link expires in ${e} minutes</li>
<li>Can only be used once</li>
<li>Should only be used by you</li>
</ul>
</div>
<p>If you didn't request this link, please ignore this email.</p>
<div class="footer">
<p>This is an automated message, please do not reply to this email.</p>
</div>
</div>
</body>
</html>
`};var h=class{constructor(e){this.options=e;}sentEmails=[];async sendMail(e){this.sentEmails.push(e),this.options?.logToConsole&&(console.log("Email sent:"),console.log(`From: ${e.from}`),console.log(`To: ${e.to}`),console.log(`Subject: ${e.subject}`),console.log(`Text: ${e.text}`)),this.options?.onSend&&this.options.onSend(e);}getSentEmails(){return [...this.sentEmails]}clearSentEmails(){this.sentEmails=[];}};var m=class{data=new Map;collections=new Map;expiryEmitter=new EventEmitter;expiryCheckInterval;constructor(e=1e3){this.expiryCheckInterval=setInterval(()=>this.checkExpiredItems(),e);}destroy(){clearInterval(this.expiryCheckInterval),this.data.clear(),this.collections.clear(),this.expiryEmitter.removeAllListeners();}checkExpiredItems(){let e=Date.now();for(let[t,r]of this.data.entries())r.expiry&&r.expiry<e&&(this.data.delete(t),this.expiryEmitter.emit("expired",t));for(let[t,r]of this.collections.entries())r.expiry&&r.expiry<e&&(this.collections.delete(t),this.expiryEmitter.emit("expired",t));}async set(e,t,r){let i=r?Date.now()+r*1e3:null;this.data.set(e,{value:t,expiry:i});}async get(e){let t=this.data.get(e);return t?t.expiry&&t.expiry<Date.now()?(this.data.delete(e),null):t.value:null}async delete(e){this.data.delete(e),this.collections.delete(e);}async addToCollection(e,t,r){this.collections.has(e)||this.collections.set(e,{items:[],expiry:r?Date.now()+r*1e3:null});let i=this.collections.get(e);r&&(i.expiry=Date.now()+r*1e3),i.items.push(t);}async removeFromCollection(e,t){let r=this.collections.get(e);r&&(r.items=r.items.filter(i=>i!==t));}async getCollection(e){let t=this.collections.get(e);return t?[...t.items]:[]}async getCollectionSize(e){let t=this.collections.get(e);return t?t.items.length:0}async removeOldestFromCollection(e){let t=this.collections.get(e);return !t||t.items.length===0?null:t.items.shift()||null}async findKeys(e){let t=e.replace(/\*/g,".*").replace(/\?/g,"."),r=new RegExp(`^${t}$`),i=Array.from(this.data.keys()).filter(a=>r.test(a)),s=Array.from(this.collections.keys()).filter(a=>r.test(a));return [...new Set([...i,...s])]}};function S(o){if(!o||o.trim()===""||(o.match(/@/g)||[]).length!==1)return false;let[t,r]=o.split("@");return !(!t||!r||o.includes("..")||!I(t)||!$(r))}function I(o){return o.startsWith('"')&&o.endsWith('"')?!o.slice(1,-1).includes('"'):o.length>64||o.startsWith(".")||o.endsWith(".")?false:/^[a-zA-Z0-9!#$%&'*+/=?^_`{|}~.-]+$/.test(o)}function $(o){if(o.startsWith("[")&&o.endsWith("]")){let t=o.slice(1,-1);return t.startsWith("IPv6:")?R(t.slice(5)):M(t)}let e=o.split(".");if(e.length===0)return false;for(let t of e)if(!t||t.length>63||!/^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/.test(t))return false;if(e.length>1){let t=e[e.length-1];if(!/^[a-zA-Z]{2,}$/.test(t))return false}return true}function M(o){return /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/.test(o)}function R(o){if(!/^[a-fA-F0-9:]+$/.test(o))return false;let t=o.split(":");return !(t.length<2||t.length>8)}var n=g(),P=o=>{let e={configFilePath:"mikroauth.config.json",args:process.argv,options:[{flag:"--jwtSecret",path:"auth.jwtSecret",defaultValue:n.auth.jwtSecret},{flag:"--magicLinkExpirySeconds",path:"auth.magicLinkExpirySeconds",defaultValue:n.auth.magicLinkExpirySeconds},{flag:"--jwtExpirySeconds",path:"auth.jwtExpirySeconds",defaultValue:n.auth.jwtExpirySeconds},{flag:"--refreshTokenExpirySeconds",path:"auth.refreshTokenExpirySeconds",defaultValue:n.auth.refreshTokenExpirySeconds},{flag:"--maxActiveSessions",path:"auth.maxActiveSessions",defaultValue:n.auth.maxActiveSessions},{flag:"--appUrl",path:"auth.appUrl",defaultValue:n.auth.appUrl},{flag:"--debug",path:"auth.debug",isFlag:true,defaultValue:n.auth.debug},{flag:"--emailSubject",path:"email.emailSubject",defaultValue:"Your Secure Login Link"},{flag:"--emailHost",path:"email.host",defaultValue:n.email.host},{flag:"--emailUser",path:"email.user",defaultValue:n.email.user},{flag:"--emailPassword",path:"email.password",defaultValue:n.email.password},{flag:"--emailPort",path:"email.port",defaultValue:n.email.port},{flag:"--emailSecure",path:"email.secure",isFlag:true,defaultValue:n.email.secure},{flag:"--emailMaxRetries",path:"email.maxRetries",defaultValue:n.email.maxRetries},{flag:"--debug",path:"email.debug",isFlag:true,defaultValue:n.email.debug},{flag:"--dir",path:"storage.databaseDirectory",defaultValue:n.storage.databaseDirectory},{flag:"--encryptionKey",path:"storage.encryptionKey",defaultValue:n.storage.encryptionKey},{flag:"--debug",path:"storage.debug",defaultValue:n.storage.debug},{flag:"--port",path:"server.port",defaultValue:n.server.port},{flag:"--host",path:"server.host",defaultValue:n.server.host},{flag:"--https",path:"server.useHttps",isFlag:true,defaultValue:n.server.useHttps},{flag:"--https",path:"server.useHttp2",isFlag:true,defaultValue:n.server.useHttp2},{flag:"--cert",path:"server.sslCert",defaultValue:n.server.sslCert},{flag:"--key",path:"server.sslKey",defaultValue:n.server.sslKey},{flag:"--ca",path:"server.sslCa",defaultValue:n.server.sslCa},{flag:"--ratelimit",path:"server.rateLimit.enabled",defaultValue:n.server.rateLimit.enabled,isFlag:true},{flag:"--rps",path:"server.rateLimit.requestsPerMinute",defaultValue:n.server.rateLimit.requestsPerMinute},{flag:"--allowed",path:"server.allowedDomains",defaultValue:n.server.allowedDomains,parser:parsers.array},{flag:"--debug",path:"server.debug",isFlag:true,defaultValue:n.server.debug}]};return o&&(e.config=o),e};var v={linkSent:"If a matching account was found, a magic link has been sent.",revokedSuccess:"All other sessions revoked successfully.",logoutSuccess:"Logged out successfully."},E=class{config;email;storage;jwtService;templates;constructor(e,t,r){let i=new MikroConf(P({auth:e.auth,email:e.email})).get();i.auth.debug&&console.log("Using configuration:",i),this.config=i,this.email=t||new h,this.storage=r||new m,this.jwtService=new y(i.auth.jwtSecret),this.templates=new w(i?.auth.templates),this.checkIfUsingDefaultCredentialsInProduction();}checkIfUsingDefaultCredentialsInProduction(){process.env.NODE_ENV==="production"&&this.config.auth.jwtSecret===g().auth.jwtSecret&&(console.error("WARNING: Using default secrets in production environment!"),process.exit(1));}generateToken(e){let t=Date.now().toString(),r=f.randomBytes(32).toString("hex");return f.createHash("sha256").update(`${e}:${t}:${r}`).digest("hex")}generateJsonWebToken(e){return this.jwtService.sign({sub:e.id,email:e.email,username:e.username,role:e.role,exp:Math.floor(Date.now()/1e3)+60*60*24})}generateRefreshToken(){return f.randomBytes(40).toString("hex")}async trackSession(e,t,r){let i=`sessions:${e}`;if(await this.storage.getCollectionSize(i)>=this.config.auth.maxActiveSessions){let a=await this.storage.removeOldestFromCollection(i);a&&await this.storage.delete(`refresh:${a}`);}await this.storage.addToCollection(i,t,this.config.auth.refreshTokenExpirySeconds),await this.storage.set(`refresh:${t}`,JSON.stringify(r),this.config.auth.refreshTokenExpirySeconds);}generateMagicLinkUrl(e){let{token:t,email:r}=e;try{return new URL(this.config.auth.appUrl),`${this.config.auth.appUrl}?token=${encodeURIComponent(t)}&email=${encodeURIComponent(r)}`}catch{throw new Error("Invalid base URL configuration")}}async createMagicLink(e){let{email:t,ip:r}=e;if(!S(t))throw new Error("Valid email required");try{let i=this.generateToken(t),s=`magic_link:${i}`,a={email:t,ipAddress:r||"unknown",createdAt:Date.now()};await this.storage.set(s,JSON.stringify(a),this.config.auth.magicLinkExpirySeconds);let d=await this.storage.findKeys("magic_link:*");for(let u of d){if(u===s)continue;let p=await this.storage.get(u);if(p)try{JSON.parse(p).email===t&&await this.storage.delete(u);}catch{}}let l=this.generateMagicLinkUrl({token:i,email:t}),c=Math.ceil(this.config.auth.magicLinkExpirySeconds/60);return await this.email.sendMail({from:this.config.email.user,to:t,subject:this.config.email.emailSubject,text:this.templates.getText(l,c),html:this.templates.getHtml(l,c)}),{message:v.linkSent}}catch(i){throw console.error(`Failed to process magic link request: ${i}`),new Error("Failed to process magic link request")}}async verifyToken(e){let{token:t,email:r}=e;try{let i=`magic_link:${t}`,s=await this.storage.get(i);if(!s)throw new Error("Invalid or expired token");let a=JSON.parse(s);if(a.email!==r)throw new Error("Email mismatch");let d=a.username,l=a.role;await this.storage.delete(i);let c=f.randomBytes(16).toString("hex"),u=this.generateRefreshToken(),p={sub:r,username:d,role:l,jti:c,lastLogin:a.createdAt,metadata:{ip:a.ipAddress},exp:Math.floor(Date.now()/1e3)+60*60*24},b=this.jwtService.sign(p,{exp:this.config.auth.jwtExpirySeconds});return await this.trackSession(r,u,{...a,tokenId:c,createdAt:Date.now()}),{accessToken:b,refreshToken:u,exp:this.config.auth.jwtExpirySeconds,tokenType:"Bearer"}}catch(i){throw console.error("Token verification error:",i),new Error("Verification failed")}}async refreshAccessToken(e){try{let t=await this.storage.get(`refresh:${e}`);if(!t)throw new Error("Invalid or expired refresh token");let r=JSON.parse(t),i=r.email;if(!i)throw new Error("Invalid refresh token data");let s=r.username,a=r.role,d=f.randomBytes(16).toString("hex"),l={sub:i,username:s,role:a,jti:d,lastLogin:r.lastLogin||r.createdAt,metadata:{ip:r.ipAddress}},c=this.jwtService.sign(l,{exp:this.config.auth.jwtExpirySeconds});return r.lastUsed=Date.now(),await this.storage.set(`refresh:${e}`,JSON.stringify(r),this.config.auth.refreshTokenExpirySeconds),{accessToken:c,refreshToken:e,exp:this.config.auth.jwtExpirySeconds,tokenType:"Bearer"}}catch(t){throw console.error("Token refresh error:",t),new Error("Token refresh failed")}}verify(e){try{return this.jwtService.verify(e)}catch{throw new Error("Invalid token")}}async logout(e){try{if(!e||typeof e!="string")throw new Error("Refresh token is required");let t=await this.storage.get(`refresh:${e}`);if(!t)return {message:v.logoutSuccess};let i=JSON.parse(t).email;if(!i)throw new Error("Invalid refresh token data");await this.storage.delete(`refresh:${e}`);let s=`sessions:${i}`;return await this.storage.removeFromCollection(s,e),{message:v.logoutSuccess}}catch(t){throw console.error("Logout error:",t),new Error("Logout failed")}}async getSessions(e){try{if(!e.user?.email)throw new Error("User not authenticated");let t=e.user.email,r=e.body?.refreshToken,i=`sessions:${t}`,a=(await this.storage.getCollection(i)).map(async l=>{try{let c=await this.storage.get(`refresh:${l}`);if(!c)return await this.storage.removeFromCollection(i,l),null;let u=JSON.parse(c);return {id:`${l.substring(0,8)}...`,createdAt:u.createdAt||0,lastLogin:u.lastLogin||u.createdAt||0,lastUsed:u.lastUsed||u.createdAt||0,metadata:{ip:u.ipAddress},isCurrentSession:l===r}}catch{return await this.storage.removeFromCollection(i,l),null}}),d=(await Promise.all(a)).filter(Boolean);return d.sort((l,c)=>c.createdAt-l.createdAt),{sessions:d}}catch(t){throw console.error("Get sessions error:",t),new Error("Failed to fetch sessions")}}async revokeSessions(e){try{if(!e.user?.email)throw new Error("User not authenticated");let t=e.user.email,r=e.body?.refreshToken,i=`sessions:${t}`,s=await this.storage.getCollection(i);for(let a of s)r&&a===r||await this.storage.delete(`refresh:${a}`);return await this.storage.delete(i),r&&await this.storage.get(`refresh:${r}`)&&await this.storage.addToCollection(i,r,this.config.auth.refreshTokenExpirySeconds),{message:v.revokedSuccess}}catch(t){throw console.error("Revoke sessions error:",t),new Error("Failed to revoke sessions")}}authenticate(e,t){try{let r=e.headers?.authorization;if(!r||!r.startsWith("Bearer "))throw new Error("Authentication required");let i=r.split(" ")[1];try{let s=this.verify(i);e.user={email:s.sub},t();}catch{throw new Error("Invalid or expired token")}}catch(r){t(r);}}};var x=class{db;PREFIX_KV="kv:";PREFIX_COLLECTION="coll:";TABLE_NAME="mikroauth";constructor(e){this.db=e;}async start(){await this.db.start();}async close(){await this.db.close();}async set(e,t,r){let i=`${this.PREFIX_KV}${e}`,s=r?Date.now()+r*1e3:void 0;await this.db.write({tableName:this.TABLE_NAME,key:i,value:t,expiration:s});}async get(e){let t=`${this.PREFIX_KV}${e}`,r=await this.db.get({tableName:this.TABLE_NAME,key:t});return r||null}async delete(e){let t=`${this.PREFIX_KV}${e}`;await this.db.delete({tableName:this.TABLE_NAME,key:t});}async addToCollection(e,t,r){let i=`${this.PREFIX_COLLECTION}${e}`,s=await this.db.get({tableName:this.TABLE_NAME,key:i}),a=[];s&&(a=JSON.parse(s)),a.includes(t)||a.push(t);let d=r?Date.now()+r*1e3:void 0;await this.db.write({tableName:this.TABLE_NAME,key:i,value:JSON.stringify(a),expiration:d});}async removeFromCollection(e,t){let r=`${this.PREFIX_COLLECTION}${e}`,i=await this.db.get({tableName:this.TABLE_NAME,key:r});if(!i)return;let s=JSON.parse(i);s=s.filter(a=>a!==t),await this.db.write({tableName:this.TABLE_NAME,key:r,value:JSON.stringify(s)});}async getCollection(e){let t=`${this.PREFIX_COLLECTION}${e}`,r=await this.db.get({tableName:this.TABLE_NAME,key:t});return r?JSON.parse(r):[]}async getCollectionSize(e){return (await this.getCollection(e)).length}async removeOldestFromCollection(e){let t=`${this.PREFIX_COLLECTION}${e}`,r=await this.db.get({tableName:this.TABLE_NAME,key:t});if(!r)return null;let i=JSON.parse(r);if(i.length===0)return null;let s=i.shift();return await this.db.write({tableName:this.TABLE_NAME,key:t,value:JSON.stringify(i)}),s}async findKeys(e){let t=e.replace(/\./g,"\\.").replace(/\*/g,".*").replace(/\?/g,"."),r=new RegExp(`^${t}$`);return (await this.db.get({tableName:this.TABLE_NAME})).filter(s=>{let a=s[0];return typeof a=="string"&&a.startsWith(this.PREFIX_KV)}).map(s=>s[0].substring(this.PREFIX_KV.length)).filter(s=>r.test(s))}};var k=class{email;sender;constructor(e){this.sender=e.user,this.email=new MikroMail({config:e});}async sendMail(e){await this.email.send({from:this.sender,to:e.to,cc:e.cc,bcc:e.bcc,subject:e.subject,text:e.text,html:e.html});}};export{h as InMemoryEmailProvider,m as InMemoryStorageProvider,E as MikroAuth,x as MikroDBProvider,k as MikroMailProvider};