sentri 2.0.0 → 2.1.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/dist/index.js CHANGED
@@ -1 +1 @@
1
- import W from'bcrypt';import K from'jsonwebtoken';import {Router}from'express';var Y=Object.assign(Object.create(null),{UNAUTHORIZED:401,TOKEN_EXPIRED:401,TOKEN_INVALID:401,INVALID_CREDENTIALS:401,FORBIDDEN:403,USER_NOT_FOUND:404,USER_ALREADY_EXISTS:409,INVALID_ROLE:400,VALIDATION_ERROR:400,CONFIGURATION_ERROR:500}),i=class extends Error{code;statusCode;constructor(r,t,s){super(t),this.name="SentriError",this.code=r,this.statusCode=s??Y[r]??500;}};async function D(e,r=12){return W.hash(e,r)}async function H(e,r){return W.compare(e,r)}var J=new WeakMap,Q=32,ee=10,re=31;function se(e){if(!e.secret||e.secret.trim().length===0)throw new i("CONFIGURATION_ERROR","secret must not be empty");if(e.secret.length<Q)throw new i("CONFIGURATION_ERROR",`secret must be at least ${Q} characters to be cryptographically safe`);let r=e.saltRounds??12;if(!Number.isInteger(r)||r<ee||r>re)throw new i("CONFIGURATION_ERROR",`saltRounds must be an integer between ${ee} and ${re}`);if(!e.validRoles||e.validRoles.length===0)throw new i("CONFIGURATION_ERROR","validRoles must contain at least one role");if(!e.adapter)throw new i("CONFIGURATION_ERROR","adapter is required")}function h(e){let r=J.get(e);if(r)return r;let t={secret:e.secret,accessExpiresIn:e.accessExpiresIn??"15m",refreshExpiresIn:e.refreshExpiresIn??"7d",algorithm:e.algorithm??"HS256",saltRounds:e.saltRounds??12,validRoles:e.validRoles,validRolesSet:new Set(e.validRoles),adapter:e.adapter,cookieName:e.cookie?.name??"refresh_token",accessCookieName:e.accessCookie?.name??"access_token"};return J.set(e,t),t}var Re=/^(\d+)([smhdw])$/,ge={s:1e3,m:6e4,h:36e5,d:864e5,w:6048e5},te=new Map;function T(e){if(typeof e=="number")return e*1e3;let r=te.get(e);if(r!==void 0)return r;let t=Re.exec(e);if(!t?.[1]||!t?.[2])throw new Error(`Invalid expiresIn: "${e}". Use e.g. "15m", "7d", "1h".`);let s=ge[t[2]];if(s===void 0)throw new Error(`Invalid expiresIn: "${e}". Use e.g. "15m", "7d", "1h".`);let a=parseInt(t[1],10)*s;return te.set(e,a),a}var oe=new Map,ne=new Map;function P(e){let r=oe.get(e);return r||(r={access:`${e}:access`,refresh:`${e}:refresh`},oe.set(e,r)),r}function ie(e,r,t,s){let a=`${t}:${s}`,n=ne.get(a);return n||(n={expiresIn:t,algorithm:s},ne.set(a,n)),K.sign(e,r,n)}function ae(e,r,t){try{let s=K.verify(e,r,{algorithms:[t]});if(typeof s=="string"||s===null)throw new i("TOKEN_INVALID","Token payload is not an object");return s}catch(s){throw s instanceof i?s:s instanceof K.TokenExpiredError?new i("TOKEN_EXPIRED","Token has expired"):new i("TOKEN_INVALID","Token is invalid or malformed")}}function x(e,r){let t=h(r),{access:s}=P(t.secret);return ie(e,s,t.accessExpiresIn,t.algorithm)}function v(e,r){let t=h(r),{refresh:s}=P(t.secret);return ie({sessionId:e},s,t.refreshExpiresIn,t.algorithm)}function b(e,r){let t=h(r),{access:s}=P(t.secret);return ae(e,s,t.algorithm)}function N(e,r){let t=h(r),{refresh:s}=P(t.secret);return ae(e,s,t.algorithm)}function I(e){return h(e).cookieName}function E(e,r){if(!e)return;let t=`${r}=`,s=0;for(;s<e.length;){for(;s<e.length&&e[s]===" ";)s++;let a=e.indexOf(";",s),n=a===-1?e.length:a;if(e.startsWith(t,s))return e.slice(s+t.length,n);s=n+1;}}function O(e,r,t){let s=t.cookie??{},a=h(t),n=T(a.refreshExpiresIn);e.cookie(I(t),r,{httpOnly:s.httpOnly??true,secure:s.secure??false,sameSite:s.sameSite??"strict",path:s.path??"/",maxAge:n});}function L(e,r){let t=r.cookie??{};e.clearCookie(I(r),{path:t.path??"/"});}function M(e){return h(e).accessCookieName}function S(e,r,t){if(!t.accessCookie)return;let s=t.accessCookie,a=h(t),n=T(a.accessExpiresIn);e.cookie(M(t),r,{httpOnly:false,secure:s.secure??false,sameSite:s.sameSite??"strict",path:s.path??"/",maxAge:n});}function X(e,r){if(!r.accessCookie)return;let t=r.accessCookie;e.clearCookie(M(r),{path:t.path??"/"});}function _(e,r){let t=e.headers.authorization;return t?.startsWith("Bearer ")?t.slice(7):E(e.headers.cookie,M(r))}async function Z(e,r){let t=h(r),s=e.roles??[],a=s.filter(u=>!t.validRolesSet.has(u));if(a.length>0)return {success:false,error:new i("INVALID_ROLE",`Invalid roles: ${a.join(", ")}`)};let n=e.identifier.trim();if(await t.adapter.user.findByIdentifier(n))return {success:false,error:new i("USER_ALREADY_EXISTS","User already exists")};let f=await D(e.password,t.saltRounds);return {success:true,user:{id:(await t.adapter.user.create({identifier:n,passwordHash:f,roles:s})).id,identifier:n,roles:s}}}async function ue(e,r){let t=h(r),s=await t.adapter.user.findByIdentifier(e.identifier.trim());if(!s)return {success:false,error:new i("INVALID_CREDENTIALS","Invalid credentials")};if(!await H(e.password,s.passwordHash))return {success:false,error:new i("INVALID_CREDENTIALS","Invalid credentials")};let n=new Date(Date.now()+T(t.refreshExpiresIn)),c=await t.adapter.session.create({userId:s.id,expiresAt:n}),f={id:s.id,identifier:s.identifier,roles:s.roles},m=x({id:s.id,identifier:s.identifier,roles:s.roles,sessionId:c.id},r),o=v(c.id,r);return {success:true,accessToken:m,refreshToken:o,user:f}}async function j(e,r){let t=h(r),s;try{({sessionId:s}=N(e,r));}catch(u){return u instanceof i?{success:false,error:u}:{success:false,error:new i("TOKEN_INVALID","Invalid refresh token")}}let a=await t.adapter.session.findById(s);if(!a)return {success:false,error:new i("UNAUTHORIZED","Session not found or revoked")};if(a.expiresAt.getTime()<Date.now())return await t.adapter.session.delete(s),{success:false,error:new i("TOKEN_EXPIRED","Session has expired")};await t.adapter.session.delete(s);let n=new Date(Date.now()+T(t.refreshExpiresIn)),c=await t.adapter.session.create({userId:a.userId,expiresAt:n}),f={id:a.user.id,identifier:a.user.identifier,roles:a.user.roles},m=x({id:f.id,identifier:f.identifier,roles:f.roles,sessionId:c.id},r),o=v(c.id,r);return {success:true,accessToken:m,refreshToken:o,user:f}}async function ce(e,r){let t;try{({sessionId:t}=N(e,r));}catch{return}await h(r).adapter.session.delete(t);}async function de(e,r){await h(r).adapter.session.deleteAllForUser(e);}async function le(e,r,t){let s=h(t),a=r.filter(m=>!s.validRolesSet.has(m));if(a.length>0)return {success:false,error:new i("INVALID_ROLE",`Invalid roles: ${a.join(", ")}`)};let n=await s.adapter.user.findById(e);if(!n)return {success:false,error:new i("USER_NOT_FOUND","User not found")};let c=new Set(n.roles);for(let m of r)c.add(m);let f=Array.from(c);return await s.adapter.user.updateRoles(e,f),{success:true,user:{id:n.id,identifier:n.identifier,roles:f}}}function k(e){return async(r,t,s)=>{let a=_(r,e);if(!a)return s(new i("UNAUTHORIZED","Missing or malformed Authorization header"));try{let n=b(a,e);if(e.isTokenRevoked&&await e.isTokenRevoked(n.sessionId))return s(new i("UNAUTHORIZED","Token has been revoked"));r.user={id:n.id,identifier:n.identifier,roles:n.roles},s();}catch(n){if(n instanceof i&&n.code==="TOKEN_EXPIRED"){let c=E(r.headers.cookie,I(e));if(!c)return s(new i("UNAUTHORIZED","Token expired. Please login again."));try{let f=await j(c,e);if(!f.success)return s(new i("UNAUTHORIZED","Session expired. Please login again."));O(t,f.refreshToken,e),S(t,f.accessToken,e),t.setHeader("X-New-Access-Token",f.accessToken),r.user=f.user,s();}catch{s(new i("UNAUTHORIZED","Session expired. Please login again."));}}else s(n);}}}function F(...e){let r=`Requires one of roles: ${e.join(", ")}`;return (t,s,a)=>{if(!t.user)return a(new i("UNAUTHORIZED","Not authenticated"));let n=t.user.roles;if(!e.some(c=>n.includes(c)))return a(new i("FORBIDDEN",r));a();}}var Ae=new i("FORBIDDEN","You do not have permission to perform this action");function fe(e){let r=typeof e=="function"?{check:e}:e;return async(t,s,a)=>{if(!t.user)return a(new i("UNAUTHORIZED","Not authenticated"));if(r.roles&&r.roles.length>0){let n=t.user.roles;if(r.roles.some(c=>n.includes(c)))return a()}try{let n=r.check(t);(n instanceof Promise?await n:n)?a():a(Ae);}catch(n){a(n);}}}var pe=8,q=72,$=255;function y(e){return new i("VALIDATION_ERROR",e)}function C(e,r,t,s){e.status(r).json({error:false,statusCode:r,message:t,data:s});}function U(e,r){e.status(r.statusCode).json({error:true,statusCode:r.statusCode,code:r.code,message:r.message,data:null});}function z(e){if(e==null||typeof e!="object"||Array.isArray(e))throw new i("VALIDATION_ERROR","Request body is missing or not a JSON object. Did you apply express.json()?");return e}function Ie(e,r){if(!r.apiKey)return;let t=e.headers["x-api-key"];if(typeof t!="string"||t!==r.apiKey)throw new i("UNAUTHORIZED","Invalid or missing API key")}function B(e){if(e)try{let r=e();r instanceof Promise&&r.catch(()=>{});}catch{}}function he(e){let r=Router(),t=e,s=e.router?.register??(o=>Z(o,t)),a=e.router?.login??(o=>ue(o,t)),n=e.router?.refresh??(o=>j(o,t)),c=e.router?.logout??(o=>o!==void 0?ce(o,t):Promise.resolve()),f=e.router?.logoutAll??(o=>de(o,t)),m=e.router?.assignRoles??((o,u)=>le(o,u,t));return r.post("/register",async(o,u,p)=>{try{Ie(o,e);let d=z(o.body),{identifier:l,password:g,roles:A}=d;if(typeof l!="string"||l.trim().length===0)throw y("identifier is required and must be a non-empty string");if(l.length>$)throw y(`identifier must not exceed ${$} characters`);if(typeof g!="string"||g.length<pe)throw y(`password is required and must be at least ${pe} characters`);if(g.length>q)throw y(`password must not exceed ${q} characters`);if(A!==void 0&&!Array.isArray(A))throw y("roles must be an array of strings when provided");if(Array.isArray(A)&&!A.every(me=>typeof me=="string"))throw y("each role must be a string");let R=Array.isArray(A)?A:void 0,w=R!==void 0?{identifier:l.trim(),password:g,roles:R}:{identifier:l.trim(),password:g},V=await s(w);if(!V.success){U(u,V.error);return}C(u,201,"User registered successfully",{user:V.user});}catch(d){p(d);}}),r.post("/login",async(o,u,p)=>{try{let d=z(o.body),{identifier:l,password:g}=d;if(typeof l!="string"||l.trim().length===0)throw y("identifier is required and must be a non-empty string");if(l.length>$)throw y(`identifier must not exceed ${$} characters`);if(typeof g!="string"||g.length===0)throw y("password is required");if(g.length>q)throw y(`password must not exceed ${q} characters`);let A=l.trim(),R=await a({identifier:A,password:g});if(!R.success){B(()=>e.hooks?.onFailedLogin?.(A,R.error)),U(u,R.error);return}B(()=>e.hooks?.onLogin?.(R.user)),O(u,R.refreshToken,e),S(u,R.accessToken,e),C(u,200,"Login successful",{accessToken:R.accessToken,user:R.user});}catch(d){p(d);}}),r.post("/refresh",async(o,u,p)=>{try{let d=E(o.headers.cookie,I(e));if(!d)throw new i("UNAUTHORIZED","Refresh token cookie is missing");let l=await n(d);if(!l.success){L(u,e),U(u,l.error);return}O(u,l.refreshToken,e),S(u,l.accessToken,e),C(u,200,"Token refreshed",{accessToken:l.accessToken});}catch(d){p(d);}}),r.post("/logout",async(o,u,p)=>{try{let d=E(o.headers.cookie,I(e));await c(d),L(u,e),X(u,e),C(u,200,"Logged out",null);}catch(d){p(d);}}),r.post("/logout-all",k(e),async(o,u,p)=>{try{let d=o.user.id;await f(d),B(()=>e.hooks?.onLogout?.(d)),L(u,e),X(u,e),C(u,200,"All sessions revoked",null);}catch(d){p(d);}}),r.get("/me",k(e),(o,u)=>{C(u,200,"OK",o.user);}),r.post("/users/:userId/roles",k(e),F("admin"),async(o,u,p)=>{try{let d=z(o.body),{roles:l}=d,g=o.params.userId,A=typeof g=="string"?g:void 0;if(!A)throw y("userId is required");if(!Array.isArray(l)||l.length===0)throw y("roles must be a non-empty array of strings");if(!l.every(w=>typeof w=="string"))throw y("each role must be a string");let R=await m(A,l);if(!R.success){U(u,R.error);return}C(u,200,"Roles assigned successfully",{user:R.user});}catch(d){p(d);}}),r.use((o,u,p,d)=>{o instanceof i?U(p,o):p.status(500).json({error:true,statusCode:500,code:"INTERNAL_SERVER_ERROR",message:"Internal server error",data:null});}),r}function G(e){return (r,t,s,a)=>{if(r instanceof i){s.status(r.statusCode).json({error:true,statusCode:r.statusCode,code:r.code,message:r.message,data:null});return}e?.onUnhandled?.(r),s.status(500).json({error:true,statusCode:500,message:"Internal server error",data:null});}}function Ce(e){se(e);let r=h(e);return {protect:()=>k(e),authorize:(...t)=>F(...t),permit:t=>fe(t),hashPassword:t=>D(t,r.saltRounds),verifyPassword:(t,s)=>H(t,s),signAccessToken:t=>x(t,e),signRefreshToken:t=>v(t,e),verifyAccessToken:t=>b(t,e),verifyRefreshToken:t=>N(t,e),getCurrentAccessToken:t=>_(t,e),router:()=>he(e),errorHandler:t=>G(t)}}function we(e){let r=e?.ttl??3e5,t=Math.max(r,5e3),s=(e?.header??"X-Idempotency-Key").toLowerCase(),a=new Set((e?.methods??["POST","PUT","PATCH"]).map(m=>m.toUpperCase())),n=e?.maxSize??1e4,c=new Map,f=setInterval(()=>{let m=Date.now();for(let[o,u]of c)u.expiresAt<=m&&c.delete(o);},t);return typeof f=="object"&&f!==null&&"unref"in f&&f.unref(),(m,o,u)=>{let p=m.headers[s];if(!p||typeof p!="string"||!a.has(m.method))return u();m.requestId=p,o.setHeader("X-Request-Id",p);let d=Date.now(),l=c.get(p);if(l&&l.expiresAt>d)return o.setHeader("X-Idempotent-Replayed","true"),o.status(l.statusCode).json(l.body);let g=o.json.bind(o);o.json=function(R){if(o.statusCode>=200&&o.statusCode<300){if(c.size>=n){let w=c.keys().next().value;w!==void 0&&c.delete(w);}c.set(p,{statusCode:o.statusCode,body:R,expiresAt:Date.now()+r});}return g(R)},u();}}export{Y as AUTH_ERROR_STATUS,i as SentriError,Ce as createAuth,G as createErrorHandler,we as createIdempotencyMiddleware,_ as getCurrentAccessToken,Z as register};
1
+ import Re from'bcrypt';import H from'jsonwebtoken';import {generateKeyPairSync,randomUUID,createPublicKey,createPrivateKey}from'crypto';import {Kysely,sql,PostgresDialect}from'kysely';import {Router}from'express';import {Redis}from'ioredis';import {Pool}from'pg';var pe=Object.assign(Object.create(null),{UNAUTHORIZED:401,TOKEN_EXPIRED:401,TOKEN_INVALID:401,INVALID_CREDENTIALS:401,FORBIDDEN:403,USER_NOT_FOUND:404,USER_ALREADY_EXISTS:409,INVALID_ROLE:400,VALIDATION_ERROR:400,CONFIGURATION_ERROR:500}),i=class extends Error{code;statusCode;constructor(r,s,t){super(s),this.name="SentriError",this.code=r,this.statusCode=t??pe[r]??500;}};async function D(e,r=12){return Re.hash(e,r)}async function K(e,r){return Re.compare(e,r)}var ye=new Map,we=new Map,nr=3600*1e3;function Ce(e){let r=ye.get(e);if(!r){let s=createPrivateKey(e),t=createPublicKey(s),n=t.export({format:"jwk"}),o=Buffer.from(e).slice(0,8).toString("base64url"),c={...n,use:"sig",kid:o};r={kid:o,publicKey:t,jwk:c},ye.set(e,r);}return r}function ke(e){let{jwk:r}=Ce(e);return {keys:[r]}}function Ie(e){return Ce(e).publicKey}async function Se(e){let r=Date.now(),s=we.get(e);if(s&&r-s.fetchedAt<nr)return s.publicKey;let t=await fetch(e);if(!t.ok)throw new i("CONFIGURATION_ERROR",`Failed to fetch public key from ${e}: HTTP ${t.status}`);let n=await t.json();if(!n.keys||n.keys.length===0)throw new i("CONFIGURATION_ERROR",`No keys found in JWKS response from ${e}`);let o=n.keys[0],c=createPublicKey({key:o,format:"jwk"});return we.set(e,{publicKey:c,fetchedAt:r}),c}var Te=new WeakMap,ve=32,Ee=10,xe=31;function _e(e){if(e.mode==="client"){if(!e.keyUri||e.keyUri.trim().length===0)throw new i("CONFIGURATION_ERROR","keyUri must not be empty");return}if(!e.secret||e.secret.trim().length===0)throw new i("CONFIGURATION_ERROR","secret must not be empty");if((e.algorithm??"HS256").startsWith("HS")&&e.secret.length<ve)throw new i("CONFIGURATION_ERROR",`secret must be at least ${ve} characters for HMAC algorithms`);let t=e.saltRounds??12;if(!Number.isInteger(t)||t<Ee||t>xe)throw new i("CONFIGURATION_ERROR",`saltRounds must be an integer between ${Ee} and ${xe}`);if(!e.validRoles||e.validRoles.length===0)throw new i("CONFIGURATION_ERROR","validRoles must contain at least one role");if(!e.dialect)throw new i("CONFIGURATION_ERROR","dialect is required in server mode")}function p(e){let r=Te.get(e);if(r)return r;let s={algorithm:e.algorithm??"HS256",accessExpiresIn:e.accessExpiresIn??"15m",refreshExpiresIn:e.refreshExpiresIn??"7d",saltRounds:e.saltRounds??12,validRoles:e.validRoles,validRolesSet:new Set(e.validRoles),cookieName:e.cookie?.name??"refresh_token",accessCookieName:e.accessCookie?.name??"access_token"};return Te.set(e,s),s}var or=/^(\d+)([smhdw])$/,ir={s:1e3,m:6e4,h:36e5,d:864e5,w:6048e5},be=new Map;function x(e){if(typeof e=="number")return e*1e3;let r=be.get(e);if(r!==void 0)return r;let s=or.exec(e);if(!s?.[1]||!s?.[2])throw new Error(`Invalid expiresIn: "${e}". Use e.g. "15m", "7d", "1h".`);let t=ir[s[2]];if(t===void 0)throw new Error(`Invalid expiresIn: "${e}". Use e.g. "15m", "7d", "1h".`);let n=parseInt(s[1],10)*t;return be.set(e,n),n}var Pe=new Map,Oe=new Map,Ne=new Map;function Ue(e){return e.startsWith("RS")||e.startsWith("PS")}function De(e){let r=Pe.get(e);return r||(r={access:`${e}:access`,refresh:`${e}:refresh`},Pe.set(e,r)),r}function cr(e){let r=Oe.get(e);return r||(r=createPrivateKey(e),Oe.set(e,r)),r}function Ke(e){let r=p(e);if(Ue(r.algorithm)){let n=cr(e.secret);return {accessKey:n,refreshKey:n}}let{access:s,refresh:t}=De(e.secret);return {accessKey:s,refreshKey:t}}function He(e,r){let s=p(e);if(Ue(s.algorithm))return Ie(e.secret);let{access:t,refresh:n}=De(e.secret);return r==="access"?t:n}function je(e,r,s,t){let n=`${s}:${t}`,o=Ne.get(n);return o||(o={expiresIn:s,algorithm:t},Ne.set(n,o)),H.sign(e,r,o)}function Le(e,r,s){try{let t=H.verify(e,r,{algorithms:[s]});if(typeof t=="string"||t===null)throw new i("TOKEN_INVALID","Token payload is not an object");return t}catch(t){throw t instanceof i?t:t instanceof H.TokenExpiredError?new i("TOKEN_EXPIRED","Token has expired"):new i("TOKEN_INVALID","Token is invalid or malformed")}}function j(e,r){let s=p(r),{accessKey:t}=Ke(r);return je(e,t,s.accessExpiresIn,s.algorithm)}function L(e,r){let s=p(r),{refreshKey:t}=Ke(r);return je({sessionId:e},t,s.refreshExpiresIn,s.algorithm)}function z(e,r){let s=p(r),t=He(r,"access");return Le(e,t,s.algorithm)}function F(e,r){let s=p(r),t=He(r,"refresh");return Le(e,t,s.algorithm)}function Fe(e,r){try{let s=H.verify(e,r,{algorithms:["RS256","RS384","RS512","PS256","PS384","PS512"]});if(typeof s=="string"||s===null)throw new i("TOKEN_INVALID","Token payload is not an object");return s}catch(s){throw s instanceof i?s:s instanceof H.TokenExpiredError?new i("TOKEN_EXPIRED","Token has expired"):new i("TOKEN_INVALID","Token is invalid or malformed")}}function E(e){return p(e).cookieName}function b(e,r){if(!e)return;let s=`${r}=`,t=0;for(;t<e.length;){for(;t<e.length&&e[t]===" ";)t++;let n=e.indexOf(";",t),o=n===-1?e.length:n;if(e.startsWith(s,t))return e.slice(t+s.length,o);t=o+1;}}function q(e,r,s){let t=s.cookie??{},n=p(s),o=x(n.refreshExpiresIn);e.cookie(E(s),r,{httpOnly:t.httpOnly??true,secure:t.secure??false,sameSite:t.sameSite??"strict",path:t.path??"/",maxAge:o});}function W(e,r){let s=r.cookie??{};e.clearCookie(E(r),{path:s.path??"/"});}function ce(e){return p(e).accessCookieName}function M(e,r,s){if(!s.accessCookie)return;let t=s.accessCookie,n=p(s),o=x(n.accessExpiresIn);e.cookie(ce(s),r,{httpOnly:false,secure:t.secure??false,sameSite:t.sameSite??"strict",path:t.path??"/",maxAge:o});}function ue(e,r){if(!r.accessCookie)return;let s=r.accessCookie;e.clearCookie(ce(r),{path:s.path??"/"});}function $(e,r){let s=e.headers.authorization;return s?.startsWith("Bearer ")?s.slice(7):b(e.headers.cookie,ce(r))}var qe=new Map;function k(e){let r=qe.get(e);return r||(r=new Kysely({dialect:e}),qe.set(e,r)),r}function de(e){try{return JSON.parse(e)}catch{return []}}function $e(e){return JSON.stringify(e)}async function Z(e,r){let s=await e.selectFrom("sentri_users").selectAll().where("identifier","=",r).executeTakeFirst();return s?{id:s.id,identifier:s.identifier,passwordHash:s.password_hash,roles:de(s.roles)}:null}async function J(e,r){let s=await e.selectFrom("sentri_users").selectAll().where("id","=",r).executeTakeFirst();return s?{id:s.id,identifier:s.identifier,passwordHash:s.password_hash,roles:de(s.roles)}:null}async function Je(e,r){let s=randomUUID();return await e.insertInto("sentri_users").values({id:s,identifier:r.identifier,password_hash:r.passwordHash,roles:$e(r.roles)}).execute(),{id:s}}async function Ge(e,r,s){await e.updateTable("sentri_users").set({identifier:s}).where("id","=",r).execute();}async function Xe(e,r,s){await e.updateTable("sentri_users").set({password_hash:s}).where("id","=",r).execute();}async function Ve(e,r,s){await e.updateTable("sentri_users").set({roles:$e(s)}).where("id","=",r).execute();}async function le(e,r){let s=randomUUID();return await e.insertInto("sentri_sessions").values({id:s,user_id:r.userId,expires_at:r.expiresAt}).execute(),{id:s}}async function Be(e,r){let s=await e.selectFrom("sentri_sessions as s").innerJoin("sentri_users as u","u.id","s.user_id").select(["s.id as session_id","s.user_id","s.expires_at","s.created_at as session_created_at","u.id as user_id_col","u.identifier","u.password_hash","u.roles"]).where("s.id","=",r).executeTakeFirst();return s?{id:s.session_id,userId:s.user_id,expiresAt:new Date(s.expires_at),createdAt:new Date(s.session_created_at),user:{id:s.user_id_col,identifier:s.identifier,passwordHash:s.password_hash,roles:de(s.roles)}}:null}async function Y(e,r){await e.deleteFrom("sentri_sessions").where("id","=",r).execute();}async function fe(e,r){await e.deleteFrom("sentri_sessions").where("user_id","=",r).execute();}async function G(e,r){let s=p(r),t=k(r.dialect),n=e.roles??[],o=n.filter(w=>!s.validRolesSet.has(w));if(o.length>0)return {success:false,error:new i("INVALID_ROLE",`Invalid roles: ${o.join(", ")}`)};let c=e.identifier.trim();if(await Z(t,c))return {success:false,error:new i("USER_ALREADY_EXISTS","User already exists")};let g=await D(e.password,s.saltRounds);return {success:true,user:{id:(await Je(t,{identifier:c,passwordHash:g,roles:n})).id,identifier:c,roles:n}}}async function Q(e,r){let s=p(r),t=k(r.dialect),n=await Z(t,e.identifier.trim());if(!n)return {success:false,error:new i("INVALID_CREDENTIALS","Invalid credentials")};if(!await K(e.password,n.passwordHash))return {success:false,error:new i("INVALID_CREDENTIALS","Invalid credentials")};let c=new Date(Date.now()+x(s.refreshExpiresIn)),d=await le(t,{userId:n.id,expiresAt:c}),g={id:n.id,identifier:n.identifier,roles:n.roles},m=j({id:n.id,identifier:n.identifier,roles:n.roles,sessionId:d.id},r),w=L(d.id,r);return {success:true,accessToken:m,refreshToken:w,user:g}}async function _(e,r){let s=p(r),t=k(r.dialect),n;try{({sessionId:n}=F(e,r));}catch(v){return v instanceof i?{success:false,error:v}:{success:false,error:new i("TOKEN_INVALID","Invalid refresh token")}}let o=await Be(t,n);if(!o)return {success:false,error:new i("UNAUTHORIZED","Session not found or revoked")};if(o.expiresAt.getTime()<Date.now())return await Y(t,n),{success:false,error:new i("TOKEN_EXPIRED","Session has expired")};await Y(t,n);let c=new Date(Date.now()+x(s.refreshExpiresIn)),d=await le(t,{userId:o.userId,expiresAt:c}),g={id:o.user.id,identifier:o.user.identifier,roles:o.user.roles},m=j({id:g.id,identifier:g.identifier,roles:g.roles,sessionId:d.id},r),w=L(d.id,r);return {success:true,accessToken:m,refreshToken:w,user:g}}async function ee(e,r){let s=k(r.dialect),t;try{({sessionId:t}=F(e,r));}catch{return}await Y(s,t);}async function re(e,r){let s=k(r.dialect);await fe(s,e);}async function ze(e,r){let s=k(r.dialect),t=await J(s,e);return t?{success:true,user:{id:t.id,identifier:t.identifier,roles:t.roles}}:{success:false,error:new i("USER_NOT_FOUND","User not found")}}async function te(e,r,s){let t=k(s.dialect),n=await J(t,e);if(!n)return {success:false,error:new i("USER_NOT_FOUND","User not found")};let o=r.trim();return await Z(t,o)?{success:false,error:new i("USER_ALREADY_EXISTS","Identifier already taken")}:(await Ge(t,e,o),{success:true,user:{id:n.id,identifier:o,roles:n.roles}})}async function se(e,r,s,t){let n=p(t),o=k(t.dialect),c=await J(o,e);if(!c)return {success:false,error:new i("USER_NOT_FOUND","User not found")};if(!await K(r,c.passwordHash))return {success:false,error:new i("INVALID_CREDENTIALS","Invalid credentials")};let g=await D(s,n.saltRounds);return await Xe(o,e,g),await fe(o,e),{success:true}}async function ne(e,r,s){let t=p(s),n=k(s.dialect),o=r.filter(m=>!t.validRolesSet.has(m));if(o.length>0)return {success:false,error:new i("INVALID_ROLE",`Invalid roles: ${o.join(", ")}`)};let c=await J(n,e);if(!c)return {success:false,error:new i("USER_NOT_FOUND","User not found")};let d=new Set(c.roles);for(let m of r)d.add(m);let g=Array.from(d);return await Ve(n,e,g),{success:true,user:{id:c.id,identifier:c.identifier,roles:g}}}function S(e){return e.mode==="client"?dr(e.keyUri):lr(e)}function dr(e){return async(r,s,t)=>{let n=r.headers.authorization,o=n?.startsWith("Bearer ")?n.slice(7):void 0;if(!o)return t(new i("UNAUTHORIZED","Missing or malformed Authorization header"));try{let c=await Se(e),d=Fe(o,c);r.user={id:d.id,identifier:d.identifier,roles:d.roles},t();}catch(c){t(c);}}}function lr(e){return async(r,s,t)=>{let n=$(r,e);if(!n)return t(new i("UNAUTHORIZED","Missing or malformed Authorization header"));try{let o=z(n,e);if(e.isTokenRevoked&&await e.isTokenRevoked(o.sessionId))return t(new i("UNAUTHORIZED","Token has been revoked"));r.user={id:o.id,identifier:o.identifier,roles:o.roles},t();}catch(o){if(o instanceof i&&o.code==="TOKEN_EXPIRED"){let c=b(r.headers.cookie,E(e));if(!c)return t(new i("UNAUTHORIZED","Token expired. Please login again."));try{let d=await _(c,e);if(!d.success)return t(new i("UNAUTHORIZED","Session expired. Please login again."));q(s,d.refreshToken,e),M(s,d.accessToken,e),s.setHeader("X-New-Access-Token",d.accessToken),r.user=d.user,t();}catch{t(new i("UNAUTHORIZED","Session expired. Please login again."));}}else t(o);}}}function X(...e){let r=`Requires one of roles: ${e.join(", ")}`;return (s,t,n)=>{if(!s.user)return n(new i("UNAUTHORIZED","Not authenticated"));let o=s.user.roles;if(!e.some(c=>o.includes(c)))return n(new i("FORBIDDEN",r));n();}}var fr=new i("FORBIDDEN","You do not have permission to perform this action");function V(e){let r=typeof e=="function"?{check:e}:e;return async(s,t,n)=>{if(!s.user)return n(new i("UNAUTHORIZED","Not authenticated"));if(r.roles&&r.roles.length>0){let o=s.user.roles;if(r.roles.some(c=>o.includes(c)))return n()}try{let o=r.check(s);(o instanceof Promise?await o:o)?n():n(fr);}catch(o){n(o);}}}function P(e){return (r,s,t,n)=>{if(r instanceof i){t.status(r.statusCode).json({error:true,statusCode:r.statusCode,code:r.code,message:r.message,data:null});return}e?.onUnhandled?.(r),t.status(500).json({error:true,statusCode:500,code:"INTERNAL_SERVER_ERROR",message:"Internal server error",data:null});}}var oe=8,O=72,N=255;function y(e){return new i("VALIDATION_ERROR",e)}function T(e,r,s,t){e.status(r).json({error:false,statusCode:r,message:s,data:t});}function U(e,r){e.status(r.statusCode).json({error:true,statusCode:r.statusCode,code:r.code,message:r.message,data:null});}function B(e){if(e==null||typeof e!="object"||Array.isArray(e))throw new i("VALIDATION_ERROR","Request body is missing or not a JSON object. Did you apply express.json()?");return e}function hr(e,r){if(!r.apiKey)return;let s=e.headers["x-api-key"];if(typeof s!="string"||s!==r.apiKey)throw new i("UNAUTHORIZED","Invalid or missing API key")}function ge(e){if(e)try{let r=e();r instanceof Promise&&r.catch(()=>{});}catch{}}function mr(e){return e.startsWith("RS")||e.startsWith("PS")}function We(e){let r=Router(),s=e,t=p(s),n=e.router?.register??(a=>G(a,s)),o=e.router?.login??(a=>Q(a,s)),c=e.router?.refresh??(a=>_(a,s)),d=e.router?.logout??(a=>a!==void 0?ee(a,s):Promise.resolve()),g=e.router?.logoutAll??(a=>re(a,s)),m=e.router?.assignRoles??((a,u)=>ne(a,u,s)),w=e.router?.changeIdentifier??((a,u)=>te(a,u,s)),v=e.router?.changePassword??((a,u,R)=>se(a,u,R,s));mr(t.algorithm)&&r.get("/keys",(a,u)=>{u.setHeader("Cache-Control","public, max-age=3600"),u.json(ke(e.secret));}),r.post("/register",async(a,u,R)=>{try{hr(a,e);let l=B(a.body),{identifier:f,password:h,roles:A}=l;if(typeof f!="string"||f.trim().length===0)throw y("identifier is required and must be a non-empty string");if(f.length>N)throw y(`identifier must not exceed ${N} characters`);if(typeof h!="string"||h.length<oe)throw y(`password is required and must be at least ${oe} characters`);if(h.length>O)throw y(`password must not exceed ${O} characters`);if(A!==void 0&&!Array.isArray(A))throw y("roles must be an array of strings when provided");if(Array.isArray(A)&&!A.every(tr=>typeof tr=="string"))throw y("each role must be a string");let C=Array.isArray(A)?A:void 0,ie=C!==void 0?{identifier:f.trim(),password:h,roles:C}:{identifier:f.trim(),password:h},ae=await n(ie);if(!ae.success){U(u,ae.error);return}T(u,201,"User registered successfully",{user:ae.user});}catch(l){R(l);}}),r.post("/login",async(a,u,R)=>{try{let l=B(a.body),{identifier:f,password:h}=l;if(typeof f!="string"||f.trim().length===0)throw y("identifier is required and must be a non-empty string");if(f.length>N)throw y(`identifier must not exceed ${N} characters`);if(typeof h!="string"||h.length===0)throw y("password is required");if(h.length>O)throw y(`password must not exceed ${O} characters`);let A=f.trim(),C=await o({identifier:A,password:h});if(!C.success){ge(()=>e.hooks?.onFailedLogin?.(A,C.error)),U(u,C.error);return}ge(()=>e.hooks?.onLogin?.(C.user)),q(u,C.refreshToken,e),M(u,C.accessToken,e),T(u,200,"Login successful",{accessToken:C.accessToken,user:C.user});}catch(l){R(l);}}),r.post("/refresh",async(a,u,R)=>{try{let l=b(a.headers.cookie,E(e));if(!l)throw new i("UNAUTHORIZED","Refresh token cookie is missing");let f=await c(l);if(!f.success){W(u,e),U(u,f.error);return}q(u,f.refreshToken,e),M(u,f.accessToken,e),T(u,200,"Token refreshed",{accessToken:f.accessToken});}catch(l){R(l);}}),r.post("/logout",async(a,u,R)=>{try{let l=b(a.headers.cookie,E(e));await d(l),W(u,e),ue(u,e),T(u,200,"Logged out",null);}catch(l){R(l);}}),r.post("/logout-all",S(e),async(a,u,R)=>{try{let l=a.user.id;await g(l),ge(()=>e.hooks?.onLogout?.(l)),W(u,e),ue(u,e),T(u,200,"All sessions revoked",null);}catch(l){R(l);}}),r.get("/me",S(e),(a,u)=>{T(u,200,"OK",a.user);});let I=V(a=>!!a.user);return r.patch("/me/identifier",S(e),I,async(a,u,R)=>{try{let l=B(a.body),{newIdentifier:f}=l;if(typeof f!="string"||f.trim().length===0)throw y("newIdentifier is required and must be a non-empty string");if(f.length>N)throw y(`newIdentifier must not exceed ${N} characters`);let h=await w(a.user.id,f);if(!h.success){U(u,h.error);return}T(u,200,"Identifier updated successfully",{user:h.user});}catch(l){R(l);}}),r.patch("/me/password",S(e),I,async(a,u,R)=>{try{let l=B(a.body),{currentPassword:f,newPassword:h}=l;if(typeof f!="string"||f.length===0)throw y("currentPassword is required");if(typeof h!="string"||h.length<oe)throw y(`newPassword must be at least ${oe} characters`);if(h.length>O)throw y(`newPassword must not exceed ${O} characters`);if(f===h)throw y("newPassword must be different from currentPassword");let A=await v(a.user.id,f,h);if(!A.success){U(u,A.error);return}T(u,200,"Password updated successfully. All sessions have been revoked.",null);}catch(l){R(l);}}),r.post("/users/:userId/roles",S(e),X("admin"),async(a,u,R)=>{try{let l=B(a.body),{roles:f}=l,h=a.params.userId,A=typeof h=="string"?h:void 0;if(!A)throw y("userId is required");if(!Array.isArray(f)||f.length===0)throw y("roles must be a non-empty array of strings");if(!f.every(ie=>typeof ie=="string"))throw y("each role must be a string");let C=await m(A,f);if(!C.success){U(u,C.error);return}T(u,200,"Roles assigned successfully",{user:C.user});}catch(l){R(l);}}),r.use(P()),r}var Ze=new Map;function Ye(e){let r=Ze.get(e);return r||(r=new Redis(e,{lazyConnect:false,enableOfflineQueue:false}),Ze.set(e,r)),r}function he(e){let r=e?.ttl??3e5,s=(e?.header??"X-Idempotency-Key").toLowerCase(),t=new Set((e?.methods??["POST","PUT","PATCH"]).map(o=>o.toUpperCase())),n=e?.redisUrl;return n?Rr(n,r,s,t):yr(r,s,t,e?.maxSize??1e4)}function Rr(e,r,s,t){let n=Ye(e),o="sentri:idempotency:";return async(c,d,g)=>{let m=c.headers[s];if(!m||typeof m!="string"||!t.has(c.method))return g();c.requestId=m,d.setHeader("X-Request-Id",m);let w=await n.get(`${o}${m}`);if(w){let I=JSON.parse(w);return d.setHeader("X-Idempotent-Replayed","true"),d.status(I.statusCode).json(I.body)}let v=d.json.bind(d);d.json=function(a){if(d.statusCode>=200&&d.statusCode<300){let u={statusCode:d.statusCode,body:a,expiresAt:Date.now()+r};n.set(`${o}${m}`,JSON.stringify(u),"PX",r).catch(()=>{});}return v(a)},g();}}function yr(e,r,s,t){let n=Math.max(e,5e3),o=new Map,c=setInterval(()=>{let d=Date.now();for(let[g,m]of o)m.expiresAt<=d&&o.delete(g);},n);return typeof c=="object"&&c!==null&&"unref"in c&&c.unref(),(d,g,m)=>{let w=d.headers[r];if(!w||typeof w!="string"||!s.has(d.method))return m();d.requestId=w,g.setHeader("X-Request-Id",w);let v=Date.now(),I=o.get(w);if(I&&I.expiresAt>v)return g.setHeader("X-Idempotent-Replayed","true"),g.status(I.statusCode).json(I.body);let a=g.json.bind(g);g.json=function(R){if(g.statusCode>=200&&g.statusCode<300){if(o.size>=t){let l=o.keys().next().value;l!==void 0&&o.delete(l);}o.set(w,{statusCode:g.statusCode,body:R,expiresAt:Date.now()+e});}return a(R)},m();}}async function er(e){await e.schema.createTable("sentri_users").ifNotExists().addColumn("id","varchar(36)",r=>r.primaryKey().notNull()).addColumn("identifier","varchar(255)",r=>r.notNull().unique()).addColumn("password_hash","varchar(255)",r=>r.notNull()).addColumn("roles","text",r=>r.notNull().defaultTo("[]")).addColumn("created_at","timestamp",r=>r.notNull().defaultTo(sql`CURRENT_TIMESTAMP`)).execute(),await e.schema.createTable("sentri_sessions").ifNotExists().addColumn("id","varchar(36)",r=>r.primaryKey().notNull()).addColumn("user_id","varchar(36)",r=>r.notNull().references("sentri_users.id").onDelete("cascade")).addColumn("expires_at","timestamp",r=>r.notNull()).addColumn("created_at","timestamp",r=>r.notNull().defaultTo(sql`CURRENT_TIMESTAMP`)).execute();}function me(e){if(_e(e),e.mode==="client")return {protect:()=>S(e),authorize:(...t)=>X(...t),permit:t=>V(t),errorHandler:t=>P(t)};let r=e,s=p(r);return {protect:()=>S(r),authorize:(...t)=>X(...t),permit:t=>V(t),hashPassword:t=>D(t,s.saltRounds),verifyPassword:(t,n)=>K(t,n),signAccessToken:t=>j(t,r),signRefreshToken:t=>L(t,r),verifyAccessToken:t=>z(t,r),verifyRefreshToken:t=>F(t,r),getCurrentAccessToken:t=>$(t,r),router:()=>We(r),migrate:()=>er(k(r.dialect)),errorHandler:t=>P(t),idempotencyMiddleware:t=>he({...t,...r.redisUrl!==void 0&&{redisUrl:r.redisUrl}}),register:t=>G(t,r),login:t=>Q(t,r),refresh:t=>_(t,r),logout:t=>ee(t,r),logoutAll:t=>re(t,r),getUser:t=>ze(t,r),changeIdentifier:(t,n)=>te(t,n,r),changePassword:(t,n,o)=>se(t,n,o,r),assignRoles:(t,n)=>ne(t,n,r)}}function rr(e){return new PostgresDialect({pool:new Pool(e)})}function kr(e){let{privateKey:r}=generateKeyPairSync("rsa",{modulusLength:2048,privateKeyEncoding:{type:"pkcs8",format:"pem"},publicKeyEncoding:{type:"spki",format:"pem"}}),s={mode:"server",dialect:rr(e.db),secret:r,algorithm:"RS256",validRoles:e.validRoles,...e.accessExpiresIn!==void 0&&{accessExpiresIn:e.accessExpiresIn},...e.refreshExpiresIn!==void 0&&{refreshExpiresIn:e.refreshExpiresIn},...e.saltRounds!==void 0&&{saltRounds:e.saltRounds},...e.apiKey!==void 0&&{apiKey:e.apiKey},...e.cookie!==void 0&&{cookie:e.cookie},...e.accessCookie!==void 0&&{accessCookie:e.accessCookie},...e.hooks!==void 0&&{hooks:e.hooks},...e.router!==void 0&&{router:e.router},...e.isTokenRevoked!==void 0&&{isTokenRevoked:e.isTokenRevoked},...e.redisUrl!==void 0&&{redisUrl:e.redisUrl}};return me(s)}export{pe as SENTRI_ERROR_STATUS,i as SentriError,me as createAuth,kr as createAuthServer,P as createErrorHandler,he as createIdempotencyMiddleware,$ as getCurrentAccessToken,G as register};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sentri",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Personal auth/authorization library for Express + Postgres",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -16,8 +16,7 @@
16
16
  },
17
17
  "files": [
18
18
  "dist",
19
- "bin",
20
- "templates"
19
+ "bin"
21
20
  ],
22
21
  "scripts": {
23
22
  "build": "tsup",
@@ -41,6 +40,7 @@
41
40
  "@types/express": "^5.0.6",
42
41
  "@types/jsonwebtoken": "^9.0.10",
43
42
  "@types/node": "^22.20.0",
43
+ "@types/pg": "^8.11.10",
44
44
  "@types/supertest": "^7.2.0",
45
45
  "@vitest/coverage-v8": "^4.1.9",
46
46
  "express": "^5.2.1",
@@ -52,17 +52,12 @@
52
52
  },
53
53
  "dependencies": {
54
54
  "bcrypt": "^6.0.0",
55
- "jsonwebtoken": "^9.0.3"
55
+ "ioredis": "^5.11.1",
56
+ "jsonwebtoken": "^9.0.3",
57
+ "kysely": "^0.27.4",
58
+ "pg": "^8.13.1"
56
59
  },
57
60
  "peerDependencies": {
58
61
  "express": ">=4.0.0"
59
- },
60
- "peerDependenciesMeta": {
61
- "@prisma/client": {
62
- "optional": true
63
- },
64
- "drizzle-orm": {
65
- "optional": true
66
- }
67
62
  }
68
63
  }
@@ -1,154 +0,0 @@
1
- // sentri — Drizzle adapter template
2
- // Generated by: npx sentri generate drizzle
3
- //
4
- // Adjust the import paths to match your project structure.
5
- // This template assumes tables: users, roles, userRoles, sessions.
6
- // Column names follow the sentri Drizzle schema convention.
7
- //
8
- // Replace NodePgDatabase with the type that matches your Drizzle driver:
9
- // import { type NodePgDatabase } from 'drizzle-orm/node-postgres'; (default)
10
- // import { type LibSQLDatabase } from 'drizzle-orm/libsql';
11
- // import { type MySql2Database } from 'drizzle-orm/mysql2';
12
-
13
- import { eq } from 'drizzle-orm';
14
- import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
15
- import { AuthError, type AuthAdapter } from 'sentri';
16
- import { users, roles, userRoles, sessions } from './schema.js';
17
-
18
- export function createAdapter(db: NodePgDatabase): AuthAdapter {
19
- if (!db) {
20
- throw new AuthError(
21
- 'CONFIGURATION_ERROR',
22
- 'createAdapter requires a Drizzle db instance. Did you forget to pass it?\n' +
23
- 'Example: createAdapter(db)',
24
- );
25
- }
26
- return {
27
- user: {
28
- async findByIdentifier(identifier) {
29
- const rows = await db
30
- .select({
31
- id: users.id,
32
- identifier: users.identifier,
33
- passwordHash: users.passwordHash,
34
- roleName: roles.name,
35
- })
36
- .from(users)
37
- .leftJoin(userRoles, eq(users.id, userRoles.userId))
38
- .leftJoin(roles, eq(userRoles.roleId, roles.id))
39
- .where(eq(users.identifier, identifier));
40
-
41
- if (rows.length === 0) return null;
42
- return {
43
- id: rows[0]!.id,
44
- identifier: rows[0]!.identifier,
45
- passwordHash: rows[0]!.passwordHash,
46
- roles: rows.flatMap((row) => (row.roleName ? [row.roleName] : [])),
47
- };
48
- },
49
-
50
- async findById(id) {
51
- const rows = await db
52
- .select({
53
- id: users.id,
54
- identifier: users.identifier,
55
- passwordHash: users.passwordHash,
56
- roleName: roles.name,
57
- })
58
- .from(users)
59
- .leftJoin(userRoles, eq(users.id, userRoles.userId))
60
- .leftJoin(roles, eq(userRoles.roleId, roles.id))
61
- .where(eq(users.id, id));
62
-
63
- if (rows.length === 0) return null;
64
- return {
65
- id: rows[0]!.id,
66
- identifier: rows[0]!.identifier,
67
- passwordHash: rows[0]!.passwordHash,
68
- roles: rows.flatMap((row) => (row.roleName ? [row.roleName] : [])),
69
- };
70
- },
71
-
72
- async create({ identifier, passwordHash, roles: roleNames }) {
73
- const [user] = await db
74
- .insert(users)
75
- .values({ identifier, passwordHash })
76
- .returning({ id: users.id });
77
-
78
- if (roleNames.length > 0) {
79
- await Promise.all(
80
- roleNames.map(async (name) => {
81
- const existing = await db.select().from(roles).where(eq(roles.name, name));
82
- const role = existing[0] ?? (await db.insert(roles).values({ name }).returning())[0];
83
- await db.insert(userRoles).values({ userId: user!.id, roleId: role!.id });
84
- }),
85
- );
86
- }
87
- return { id: user!.id };
88
- },
89
-
90
- async updateRoles(userId, roleNames) {
91
- await db.delete(userRoles).where(eq(userRoles.userId, userId));
92
- await Promise.all(
93
- roleNames.map(async (name) => {
94
- const existing = await db.select().from(roles).where(eq(roles.name, name));
95
- const role = existing[0] ?? (await db.insert(roles).values({ name }).returning())[0];
96
- await db.insert(userRoles).values({ userId, roleId: role!.id });
97
- }),
98
- );
99
- },
100
- },
101
-
102
- session: {
103
- async create({ userId, expiresAt }) {
104
- const [session] = await db
105
- .insert(sessions)
106
- .values({ userId, expiresAt })
107
- .returning({ id: sessions.id });
108
- return { id: session!.id };
109
- },
110
-
111
- async findById(sessionId) {
112
- const rows = await db
113
- .select({
114
- sessionId: sessions.id,
115
- sessionUserId: sessions.userId,
116
- sessionExpiresAt: sessions.expiresAt,
117
- sessionCreatedAt: sessions.createdAt,
118
- userId: users.id,
119
- identifier: users.identifier,
120
- passwordHash: users.passwordHash,
121
- roleName: roles.name,
122
- })
123
- .from(sessions)
124
- .innerJoin(users, eq(sessions.userId, users.id))
125
- .leftJoin(userRoles, eq(users.id, userRoles.userId))
126
- .leftJoin(roles, eq(userRoles.roleId, roles.id))
127
- .where(eq(sessions.id, sessionId));
128
-
129
- if (rows.length === 0) return null;
130
- const first = rows[0]!;
131
- return {
132
- id: first.sessionId,
133
- userId: first.sessionUserId,
134
- expiresAt: first.sessionExpiresAt,
135
- createdAt: first.sessionCreatedAt,
136
- user: {
137
- id: first.userId,
138
- identifier: first.identifier,
139
- passwordHash: first.passwordHash,
140
- roles: rows.flatMap((row) => (row.roleName ? [row.roleName] : [])),
141
- },
142
- };
143
- },
144
-
145
- async delete(sessionId) {
146
- await db.delete(sessions).where(eq(sessions.id, sessionId));
147
- },
148
-
149
- async deleteAllForUser(userId) {
150
- await db.delete(sessions).where(eq(sessions.userId, userId));
151
- },
152
- },
153
- };
154
- }
@@ -1,125 +0,0 @@
1
- // sentri — auth config template (Drizzle)
2
- // Generated by: npx sentri generate drizzle
3
- //
4
- // Update validRoles to match the roles your application uses.
5
- // Add JWT_SECRET to your .env file.
6
- // If you already have a db instance defined elsewhere, import it instead.
7
-
8
- import { createAuth } from 'sentri';
9
- import { db } from '../db.js'; // adjust path to your db instance
10
- import { createAdapter } from './adapter.js';
11
-
12
- export const auth = createAuth({
13
- secret: process.env.JWT_SECRET!,
14
- validRoles: ['user', 'admin'] as const,
15
- adapter: createAdapter(db),
16
- accessExpiresIn: '5m', // short-lived; silent refresh happens automatically in protect()
17
- // refreshExpiresIn: '7d',
18
- // algorithm: 'HS256',
19
- // saltRounds: 12,
20
- // apiKey: process.env.REGISTER_API_KEY, // when set, POST /register requires X-Api-Key header
21
-
22
- // Access token stored in a non-httpOnly cookie — browser JS can read it via
23
- // document.cookie or getCurrentAccessToken(req). protect() reads from this
24
- // cookie automatically when no Authorization header is present.
25
- accessCookie: {
26
- secure: process.env.NODE_ENV === 'production',
27
- // name: 'access_token',
28
- // sameSite: 'strict',
29
- // path: '/',
30
- },
31
-
32
- // Refresh token stored in an httpOnly cookie — not readable by JS.
33
- cookie: {
34
- secure: process.env.NODE_ENV === 'production',
35
- // name: 'refresh_token',
36
- // httpOnly: true,
37
- // sameSite: 'strict',
38
- // path: '/',
39
- },
40
-
41
- // hooks: {
42
- // // Fired after every successful login — good for audit logs, notifications.
43
- // onLogin: async (user) => {
44
- // await auditLog.record('login', user.id);
45
- // },
46
- // // Fired after every failed login — good for rate limiting, alerting.
47
- // onFailedLogin: (identifier, error) => {
48
- // rateLimiter.hit(`login:${identifier}`);
49
- // },
50
- // // Fired after logout (single session or all sessions).
51
- // onLogout: async (userId) => {
52
- // await cache.invalidate(userId);
53
- // },
54
- // },
55
-
56
- // isTokenRevoked: async (sessionId) => {
57
- // // Optional: immediate revocation via Redis (called on every protected request).
58
- // // Keep this fast — a slow check negates the performance benefit of stateless JWTs.
59
- // return await redis.sismember('revoked_sessions', sessionId);
60
- // },
61
-
62
- // router: {
63
- // register: async (input) => {
64
- // // custom register logic — must return RegisterResult
65
- // },
66
- // login: async (input) => {
67
- // // custom login logic — must return AuthResult
68
- // },
69
- // refresh: async (refreshToken) => {
70
- // // custom refresh logic — must return RefreshResult
71
- // },
72
- // logout: async (refreshToken) => {
73
- // // custom logout logic
74
- // },
75
- // logoutAll: async (userId) => {
76
- // // custom logout-all logic
77
- // },
78
- // assignRoles: async (userId, roles) => {
79
- // // custom assignRoles logic — must return AssignRolesResult
80
- // },
81
- // },
82
- });
83
-
84
- // --- Express app setup ---
85
- //
86
- // import express from 'express';
87
- // import { SentriError, createIdempotencyMiddleware, getCurrentAccessToken } from 'sentri';
88
- //
89
- // const app = express();
90
- // app.use(express.json());
91
- //
92
- // // Optional: make create/update operations idempotent via X-Idempotency-Key header
93
- // app.use(createIdempotencyMiddleware());
94
- //
95
- // // Mount the auth router (POST /auth/register, /auth/login, etc.)
96
- // app.use('/auth', auth.router());
97
- //
98
- // // Your own routes — protect() reads token from Authorization header OR access_token cookie
99
- // app.get('/protected', auth.protect(), (req, res) => {
100
- // res.json(req.user);
101
- // });
102
- //
103
- // // Read the raw access token if needed (header → cookie fallback)
104
- // app.get('/debug', (req, res) => {
105
- // const token = auth.getCurrentAccessToken(req); // or: getCurrentAccessToken(req, config)
106
- // res.json({ hasToken: !!token });
107
- // });
108
- //
109
- // // Domain-specific error by extending SentriError
110
- // class NotFoundError extends SentriError {
111
- // constructor(resource: string) {
112
- // super('NOT_FOUND', `${resource} not found`, 404);
113
- // }
114
- // }
115
- //
116
- // app.get('/items/:id', auth.protect(), async (req, res) => {
117
- // const item = await db.query.items.findFirst({ where: (t, { eq }) => eq(t.id, req.params['id']) });
118
- // if (!item) throw new NotFoundError('Item');
119
- // res.json(item);
120
- // });
121
- //
122
- // // Mount AFTER all routes — catches SentriError from sentri AND your subclasses
123
- // app.use(auth.errorHandler());
124
- //
125
- // app.listen(3000);
@@ -1,47 +0,0 @@
1
- // sentri — Drizzle schema template (PostgreSQL / node-postgres)
2
- // Generated by: npx sentri generate drizzle
3
- //
4
- // Field mapping: library uses `identifier` internally, but your DB column
5
- // can be named anything — adjust the first argument of text() on the
6
- // identifier column to match your existing schema, or leave it as-is.
7
- //
8
- // text('email') — login via email
9
- // text('username') — login via username
10
- // text('phone') — login via phone number
11
-
12
- import { pgTable, primaryKey, text, timestamp } from 'drizzle-orm/pg-core';
13
-
14
- export const users = pgTable('users', {
15
- id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
16
- identifier: text('email').notNull().unique(),
17
- passwordHash: text('password_hash').notNull(),
18
- createdAt: timestamp('created_at').notNull().defaultNow(),
19
- updatedAt: timestamp('updated_at').notNull().$onUpdateFn(() => new Date()),
20
- });
21
-
22
- export const roles = pgTable('roles', {
23
- id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
24
- name: text('name').notNull().unique(),
25
- });
26
-
27
- export const userRoles = pgTable(
28
- 'user_roles',
29
- {
30
- userId: text('user_id')
31
- .notNull()
32
- .references(() => users.id, { onDelete: 'cascade' }),
33
- roleId: text('role_id')
34
- .notNull()
35
- .references(() => roles.id, { onDelete: 'cascade' }),
36
- },
37
- (table) => [primaryKey({ columns: [table.userId, table.roleId] })],
38
- );
39
-
40
- export const sessions = pgTable('sessions', {
41
- id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
42
- userId: text('user_id')
43
- .notNull()
44
- .references(() => users.id, { onDelete: 'cascade' }),
45
- expiresAt: timestamp('expires_at').notNull(),
46
- createdAt: timestamp('created_at').notNull().defaultNow(),
47
- });
@@ -1,122 +0,0 @@
1
- // sentri — Prisma adapter template
2
- // Generated by: npx sentri generate prisma
3
- //
4
- // Adjust the Prisma model/field names below if your schema differs from the
5
- // template produced by `npx sentri init`.
6
-
7
- import type { PrismaClient } from '@prisma/client';
8
- import { AuthError, type AuthAdapter } from 'sentri';
9
-
10
- export function createAdapter(prisma: PrismaClient): AuthAdapter {
11
- if (!prisma) {
12
- throw new AuthError(
13
- 'CONFIGURATION_ERROR',
14
- 'createAdapter requires a PrismaClient instance. Did you forget to pass it?\n' +
15
- 'Example: createAdapter(prisma)',
16
- );
17
- }
18
- return {
19
- user: {
20
- async findByIdentifier(identifier) {
21
- const user = await prisma.user.findUnique({
22
- where: { identifier },
23
- include: { userRoles: { include: { role: true } } },
24
- });
25
- if (!user) return null;
26
- return {
27
- id: user.id,
28
- identifier: user.identifier,
29
- passwordHash: user.passwordHash,
30
- roles: user.userRoles.map((userRole) => userRole.role.name),
31
- };
32
- },
33
-
34
- async findById(id) {
35
- const user = await prisma.user.findUnique({
36
- where: { id },
37
- include: { userRoles: { include: { role: true } } },
38
- });
39
- if (!user) return null;
40
- return {
41
- id: user.id,
42
- identifier: user.identifier,
43
- passwordHash: user.passwordHash,
44
- roles: user.userRoles.map((userRole) => userRole.role.name),
45
- };
46
- },
47
-
48
- async create({ identifier, passwordHash, roles }) {
49
- const user = await prisma.user.create({
50
- data: {
51
- identifier,
52
- passwordHash,
53
- userRoles: {
54
- create: await Promise.all(
55
- roles.map(async (name) => {
56
- const role = await prisma.role.upsert({
57
- where: { name },
58
- update: {},
59
- create: { name },
60
- });
61
- return { roleId: role.id };
62
- }),
63
- ),
64
- },
65
- },
66
- });
67
- return { id: user.id };
68
- },
69
-
70
- async updateRoles(userId, roles) {
71
- await prisma.userRole.deleteMany({ where: { userId } });
72
- await Promise.all(
73
- roles.map(async (name) => {
74
- const role = await prisma.role.upsert({
75
- where: { name },
76
- update: {},
77
- create: { name },
78
- });
79
- await prisma.userRole.create({ data: { userId, roleId: role.id } });
80
- }),
81
- );
82
- },
83
- },
84
-
85
- session: {
86
- async create({ userId, expiresAt }) {
87
- const session = await prisma.session.create({ data: { userId, expiresAt } });
88
- return { id: session.id };
89
- },
90
-
91
- async findById(sessionId) {
92
- const session = await prisma.session.findUnique({
93
- where: { id: sessionId },
94
- include: {
95
- user: { include: { userRoles: { include: { role: true } } } },
96
- },
97
- });
98
- if (!session) return null;
99
- return {
100
- id: session.id,
101
- userId: session.userId,
102
- expiresAt: session.expiresAt,
103
- createdAt: session.createdAt,
104
- user: {
105
- id: session.user.id,
106
- identifier: session.user.identifier,
107
- passwordHash: session.user.passwordHash,
108
- roles: session.user.userRoles.map((userRole) => userRole.role.name),
109
- },
110
- };
111
- },
112
-
113
- async delete(sessionId) {
114
- await prisma.session.delete({ where: { id: sessionId } }).catch(() => {});
115
- },
116
-
117
- async deleteAllForUser(userId) {
118
- await prisma.session.deleteMany({ where: { userId } });
119
- },
120
- },
121
- };
122
- }