@elysiumjs/themis 0.0.0-stage → 0.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 ADDED
@@ -0,0 +1 @@
1
+ var __legacyDecorateClassTS=function(decorators,target,key,desc){var c=arguments.length,r=c<3?target:desc===null?desc=Object.getOwnPropertyDescriptor(target,key):desc,d;if(typeof Reflect==="object"&&typeof Reflect.decorate==="function")r=Reflect.decorate(decorators,target,key,desc);else for(var i=decorators.length-1;i>=0;i--)if(d=decorators[i])r=(c<3?d(r):c>3?d(target,key,r):d(target,key))||r;return c>3&&r&&Object.defineProperty(target,key,r),r};var encoder=new TextEncoder,decoder=new TextDecoder,utf8=(value)=>encoder.encode(value),fromUtf8=(bytes)=>decoder.decode(bytes),randomBytes=(length)=>crypto.getRandomValues(new Uint8Array(length)),toBase64Url=(bytes)=>{let binary="";for(let byte of bytes)binary+=String.fromCharCode(byte);return btoa(binary).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"")},fromBase64Url=(value)=>{let base64=value.replace(/-/g,"+").replace(/_/g,"/"),binary=atob(base64+"=".repeat((4-base64.length%4)%4));return Uint8Array.from(binary,(char)=>char.charCodeAt(0))},toHex=(bytes)=>[...bytes].map((byte)=>byte.toString(16).padStart(2,"0")).join(""),randomToken=(bytes=32)=>toBase64Url(randomBytes(bytes)),safeEqual=(a,b)=>{let left=utf8(a),right=utf8(b);if(left.length!==right.length)return!1;let diff=0;for(let i=0;i<left.length;i++)diff|=left[i]^right[i];return diff===0},sha256=async(value)=>toHex(new Uint8Array(await crypto.subtle.digest("SHA-256",utf8(value)))),hmacKeys=new Map,hmacKey=(secret,hash="SHA-256")=>{let id=`${hash}\x00${secret}`,key=hmacKeys.get(id);if(!key)key=crypto.subtle.importKey("raw",utf8(secret),{name:"HMAC",hash},!1,["sign","verify"]),hmacKeys.set(id,key);return key},hmac=async(secret,value)=>toBase64Url(new Uint8Array(await crypto.subtle.sign("HMAC",await hmacKey(secret),utf8(value)))),verifyHmac=async(secret,value,signature)=>{try{return await crypto.subtle.verify("HMAC",await hmacKey(secret),fromBase64Url(signature),utf8(value))}catch{return!1}},aesKeys=new Map,aesKey=(secret,purpose)=>{let id=`${purpose}\x00${secret}`,key=aesKeys.get(id);if(!key)key=crypto.subtle.importKey("raw",utf8(secret),"HKDF",!1,["deriveKey"]).then((base)=>crypto.subtle.deriveKey({name:"HKDF",hash:"SHA-256",salt:new Uint8Array,info:utf8(`elysium:${purpose}`)},base,{name:"AES-GCM",length:256},!1,["encrypt","decrypt"])),aesKeys.set(id,key);return key},encrypt=async(secret,purpose,plaintext)=>{let iv=randomBytes(12),sealed=new Uint8Array(await crypto.subtle.encrypt({name:"AES-GCM",iv},await aesKey(secret,purpose),utf8(plaintext))),out=new Uint8Array(iv.length+sealed.length);return out.set(iv),out.set(sealed,iv.length),toBase64Url(out)},decrypt=async(secret,purpose,sealed)=>{try{let bytes=fromBase64Url(sealed),plain=await crypto.subtle.decrypt({name:"AES-GCM",iv:bytes.slice(0,12)},await aesKey(secret,purpose),bytes.slice(12));return fromUtf8(new Uint8Array(plain))}catch{return null}};var ApiKeys;((ApiKeys)=>(ApiKeys.generate=async(options={})=>{let key=`${options.prefix??"key"}_${randomToken(32)}`;return{key,hash:await ApiKeys.hash(key)}},ApiKeys.hash=(key)=>sha256(key),ApiKeys.verify=async(key,storedHash)=>safeEqual(await ApiKeys.hash(key),storedHash)))(ApiKeys||={});import{Metadata,Middleware as Middleware2,Service as Service2}from"@elysiumjs/core";import{Application,Service}from"@elysiumjs/core";var THEMIS_CONFIG="themis.settings";import{DomainError,ElysiumError}from"@elysiumjs/core";import{UnauthenticatedError}from"@elysiumjs/core";class CsrfTokenMismatchError extends DomainError.for("CSRF_TOKEN_MISMATCH",403){constructor(){super("The CSRF token is missing or does not match.")}}class MissingThemisConfigError extends ElysiumError{constructor(){super("Themis needs ThemisModule.with({ secret, ... }) in the application.")}}class NoSessionError extends ElysiumError{constructor(){super("There is no session here. Register SessionMiddleware on the application or the controller (@Middleware.register(SessionMiddleware)).")}}class UnknownGuardError extends ElysiumError{constructor(name){super(`No guard named "${name}" is configured in ThemisModule.with({ guards }).`)}}class WeakSecretError extends ElysiumError{constructor(what){super(`The ${what} must be at least ${MIN_SECRET_BYTES} bytes long: use a random value kept in an environment secret.`)}}var MIN_SECRET_BYTES=32,assertStrongSecret=(secret,what)=>{if(new TextEncoder().encode(secret).length<MIN_SECRET_BYTES)throw new WeakSecretError(what)};class SessionTooLargeError extends ElysiumError{constructor(size){super(`The cookie session is ${size} characters long, over the 4000 browsers keep. Keep less in it, or use a session store.`)}}var currentContext=()=>Application.context.getStore()?.get("http:context"),themisConfig=()=>{let config=Service.exists(THEMIS_CONFIG)?Service.get(THEMIS_CONFIG):null;if(!config)throw new MissingThemisConfigError;return config},readCookie=(request,name)=>{let header=request.headers.get("cookie");if(!header)return;for(let part of header.split(";")){let index=part.indexOf("=");if(index<0)continue;if(part.slice(0,index).trim()===name)try{return decodeURIComponent(part.slice(index+1).trim())}catch{return}}return},writeCookie=(context,name,value,options)=>{let parts=[`${name}=${encodeURIComponent(value)}`,`Path=${options.path??"/"}`];if(options.domain)parts.push(`Domain=${options.domain}`);if(options.maxAge!==void 0)parts.push(`Max-Age=${options.maxAge}`);if(options.httpOnly??!0)parts.push("HttpOnly");if(options.secure??!0)parts.push("Secure");parts.push(`SameSite=${options.sameSite??"Lax"}`);let cookie=parts.join("; "),headers=context.set.headers,existing=headers["set-cookie"];if(existing===void 0)headers["set-cookie"]=cookie;else headers["set-cookie"]=[...Array.isArray(existing)?existing:[String(existing)],cookie]};import{DomainError as DomainError2}from"@elysiumjs/core";import{jwtVerify,SignJWT}from"jose";class InvalidTokenError extends DomainError2.for("UNAUTHENTICATED",401){constructor(reason){super(`The token is invalid: ${reason}.`)}}var keyOf=(key)=>{if(!("secret"in key))return{key:key.key,algorithm:key.algorithm};return assertStrongSecret(key.secret,"JWT secret"),{key:utf8(key.secret),algorithm:"HS256"}},Jwt;((Jwt)=>(Jwt.sign=async(claims,options)=>{let{key,algorithm}=keyOf(options),jwt=new SignJWT(claims).setProtectedHeader({alg:algorithm,typ:"JWT"}).setIssuedAt();if(options.expiresIn!==void 0)jwt=jwt.setExpirationTime(typeof options.expiresIn==="number"?Math.floor(Date.now()/1000)+options.expiresIn:options.expiresIn);if(options.issuer)jwt=jwt.setIssuer(options.issuer);if(options.audience)jwt=jwt.setAudience(options.audience);if(options.subject)jwt=jwt.setSubject(options.subject);return jwt.sign(key)},Jwt.verify=async(token,options)=>{let{key,algorithm}=keyOf(options);try{let{payload}=await jwtVerify(token,key,{algorithms:[algorithm],issuer:options.issuer,audience:options.audience,clockTolerance:options.clockTolerance??5});return payload}catch(error){let code=error.code;throw new InvalidTokenError(code==="ERR_JWT_EXPIRED"?"it has expired":code==="ERR_JWT_CLAIM_VALIDATION_FAILED"?error.message:"it does not verify")}}))(Jwt||={});import{currentEnvironment,getLogger,Middleware}from"@elysiumjs/core";class MemorySessionStore{#sessions=new Map;async load(id){let entry=this.#sessions.get(id);if(!entry)return null;if(entry.expiresAt<=Date.now())return this.#sessions.delete(id),null;return JSON.parse(entry.data)}async save(id,data,ttlSeconds){this.#sessions.set(id,{data:JSON.stringify(data),expiresAt:Date.now()+ttlSeconds*1000})}async destroy(id){this.#sessions.delete(id)}}var FLASH="_flash",SESSION="themis:session",DEFAULT_TTL=604800,defaultStore=new MemorySessionStore,warned=!1,storeOf=(config)=>{let store=config.session?.store;if(store)return store;if(!warned&&currentEnvironment()!=="test")warned=!0,getLogger().warn("Sessions live in the memory of this process: they are lost on restart and not shared between instances. Set session.store in ThemisModule.with().");return defaultStore},MAX_COOKIE_SESSION=4000;class Session{id;isNew;#data;#flashed;#dirty=!1;#destroyed=!1;#previousId=null;constructor(id,data,isNew){this.id=id;this.isNew=isNew;let{[FLASH]:flashed,...rest}=data;if(this.#data=rest,this.#flashed=flashed??{},flashed!==void 0)this.#dirty=!0}static current(){let session=currentContext()?.[SESSION];if(!session)throw new NoSessionError;return session}get(key){return key in this.#data?this.#data[key]:this.#flashed[key]}has(key){return key in this.#data||key in this.#flashed}set(key,value){this.#data[key]=value,this.#dirty=!0}forget(key){if(!(key in this.#data))return;delete this.#data[key],this.#dirty=!0}pull(key){let value=this.get(key);return this.forget(key),value}flash(key,value){let flash=this.#data[FLASH]??={};flash[key]=value,this.#dirty=!0}all(){let{[FLASH]:_,...data}=this.#data;return{...this.#flashed,...data}}regenerate(){this.#previousId??=this.isNew?null:this.id,this.id=randomToken(32),this.#dirty=!0}destroy(){this.#destroyed=!0,this.#data={},this.#flashed={}}get state(){return{data:this.#data,dirty:this.#dirty,destroyed:this.#destroyed,previousId:this.#previousId}}}var COOKIE_PURPOSE="session-cookie",loadSession=async(context)=>{let loaded=context[SESSION];if(loaded)return loaded;let config=themisConfig(),store=storeOf(config),raw=readCookie(context.request,config.session?.cookie?.name??"elysium_session"),session;if(raw&&store==="cookie"){let opened=await decrypt(config.secret,COOKIE_PURPOSE,raw),parsed=opened?JSON.parse(opened):null;if(parsed&&parsed.exp>Date.now())session=new Session(parsed.id,parsed.data,!1)}else if(raw&&store!=="cookie"){let dot=raw.lastIndexOf("."),id=raw.slice(0,dot);if(dot>0&&await verifyHmac(config.secret,id,raw.slice(dot+1))){let data=await store.load(id);if(data)session=new Session(id,data,!1)}}return session??=new Session(randomToken(32),{},!0),context[SESSION]=session,session},commitSession=async(context)=>{let session=context[SESSION];if(!session)return;let config=themisConfig(),store=storeOf(config),cookie=config.session?.cookie??{},name=cookie.name??"elysium_session",ttl=config.session?.ttl??DEFAULT_TTL,{data,dirty,destroyed,previousId}=session.state;if(store!=="cookie"&&previousId)await store.destroy(previousId);if(destroyed){if(store!=="cookie"&&!session.isNew)await store.destroy(session.id);if(!session.isNew)writeCookie(context,name,"",{...cookie,maxAge:0});return}if(!dirty)return;if(store==="cookie"){let sealed=await encrypt(config.secret,COOKIE_PURPOSE,JSON.stringify({id:session.id,data,exp:Date.now()+ttl*1000}));if(sealed.length>MAX_COOKIE_SESSION)throw new SessionTooLargeError(sealed.length);writeCookie(context,name,sealed,{...cookie,maxAge:ttl});return}await store.save(session.id,data,ttl),writeCookie(context,name,`${session.id}.${await hmac(config.secret,session.id)}`,{...cookie,maxAge:ttl})};class SessionMiddleware extends Middleware{async onBeforeHandle(context){await loadSession(context)}async onAfterHandle(context){await commitSession(context)}async onError(context){if(Number(context.error?.status??500)<500)await commitSession(context)}}var built=new WeakMap,guardOf=(entry)=>{if(typeof entry!=="function")return entry;let guard=built.get(entry);if(!guard)built.set(entry,guard=Service2.make(entry));return guard},USER="currentUser",GUARD="themis:guard",SESSION_USER="auth:user",sessionGuard=(options={})=>({async authenticate(context){let id=(await loadSession(context)).get(SESSION_USER);if(id===void 0)return;return options.user?options.user(id):id}}),bearer=(request)=>{let header=request.headers.get("authorization");return(header?/^Bearer\s+(\S+)$/i.exec(header):null)?.[1]},jwtGuard=(options)=>({async authenticate(context){let token=bearer(context.request);if(!token||token.split(".").length!==3)return;let claims=await Jwt.verify(token,options);return options.user?options.user(claims):claims}}),apiKeyGuard=(options)=>({async authenticate(context){let fromHeader=context.request.headers.get(options.header??"x-api-key")??void 0,fromBearer=bearer(context.request),key=fromHeader??(fromBearer&&(!options.prefix||fromBearer.startsWith(`${options.prefix}_`))?fromBearer:void 0);if(!key)return;let user=await options.find(await ApiKeys.hash(key));if(user===void 0||user===null)throw new UnauthenticatedError;return user}}),OPTIONAL=Symbol.for("elysium:themis:auth-optional");class AuthMiddleware extends Middleware2{async onBeforeHandle(context){let guards=themisConfig().guards??{},names=this.guards.length>0?this.guards:Object.keys(guards);for(let name of names){let entry=guards[name];if(!entry)throw new UnknownGuardError(name);let user=await guardOf(entry).authenticate(context);if(user!==void 0&&user!==null){context[USER]=user,context[GUARD]=name;return}}if(Metadata.get(OPTIONAL,this.constructor)!==!0)throw new UnauthenticatedError}}function Auth(options={}){let base=options.guards?AuthMiddleware.guards(options.guards):AuthMiddleware;class ConfiguredAuthMiddleware extends base{}if(options.optional)Metadata.define(OPTIONAL,!0,ConfiguredAuthMiddleware);return Middleware2.register(ConfiguredAuthMiddleware)}((Auth)=>(Auth.user=()=>currentContext()?.[USER],Auth.guard=()=>currentContext()?.[GUARD],Auth.check=()=>Auth.user()!==void 0,Auth.login=(id)=>{let session=Session.current();session.regenerate(),session.set(SESSION_USER,id)},Auth.logout=()=>{Session.current().destroy()}))(Auth||={});import{Middleware as Middleware3}from"@elysiumjs/core";var TOKEN="_csrf",SAFE_METHODS=new Set(["GET","HEAD","OPTIONS"]);class CsrfMiddleware extends Middleware3{async onBeforeHandle(context){let config=themisConfig(),session=await loadSession(context),expected=Csrf.tokenOf(session);if(SAFE_METHODS.has(context.request.method)||session.isNew)return;let body=context.body,presented=context.request.headers.get(config.csrf?.header??"x-csrf-token")??(typeof body?.[config.csrf?.field??TOKEN]==="string"?body[config.csrf?.field??TOKEN]:void 0);if(!presented||!safeEqual(presented,expected))throw new CsrfTokenMismatchError}}var Csrf;((Csrf)=>(Csrf.tokenOf=(session)=>{let token=session.get(TOKEN);if(!token)token=randomToken(32),session.set(TOKEN,token);return token},Csrf.token=()=>Csrf.tokenOf(Session.current())))(Csrf||={});import{ElysiumError as ElysiumError2}from"@elysiumjs/core";var onWorkers=()=>globalThis.navigator?.userAgent==="Cloudflare-Workers";class Pbkdf2PasswordHasher{options;prefix="$pbkdf2-sha256$";constructor(options={}){this.options=options}get iterations(){return this.options.iterations??(onWorkers()?1e5:600000)}async hash(password){let salt=randomBytes(16),key=await this.derive(password,salt,this.iterations);return`${this.prefix}i=${this.iterations}$${toBase64Url(salt)}$${toBase64Url(key)}`}async verify(password,hash){let parsed=this.parse(hash);if(!parsed)return!1;let key=await this.derive(password,parsed.salt,parsed.iterations);return safeEqual(toBase64Url(key),parsed.key)}needsRehash(hash){let parsed=this.parse(hash);return!parsed||parsed.iterations<this.iterations}parse(hash){let match=/^\$pbkdf2-sha256\$i=(\d+)\$([\w-]+)\$([\w-]+)$/.exec(hash);if(!match)return null;return{iterations:Number(match[1]),salt:fromBase64Url(match[2]),key:match[3]}}async derive(password,salt,iterations){let base=await crypto.subtle.importKey("raw",utf8(password),"PBKDF2",!1,["deriveBits"]),bits=await crypto.subtle.deriveBits({name:"PBKDF2",hash:"SHA-256",salt,iterations},base,256);return new Uint8Array(bits)}}var bunPassword=()=>globalThis.Bun?.password;class BunPasswordHasher{options;prefix="$argon2id$";constructor(options={}){this.options=options}get api(){let api=bunPassword();if(!api)throw new ElysiumError2("BunPasswordHasher needs the Bun runtime. Use Pbkdf2PasswordHasher, or ScryptPasswordHasher from @elysiumjs/themis/scrypt.");return api}hash(password){return this.api.hash(password,{algorithm:"argon2id",...this.options})}verify(password,hash){return this.api.verify(password,hash).catch(()=>!1)}needsRehash(hash){let match=/^\$argon2id\$v=\d+\$m=(\d+),t=(\d+)/.exec(hash);if(!match)return!0;return Number(match[1])<(this.options.memoryCost??65536)||Number(match[2])<(this.options.timeCost??2)}}var configured,known=[],defaultHasher=()=>configured??=bunPassword()?new BunPasswordHasher:new Pbkdf2PasswordHasher,Password;((Password)=>{Password.use=(hasher)=>{configured=hasher,Password.register(hasher)},Password.register=(hasher)=>{let index=known.findIndex((entry)=>entry.prefix===hasher.prefix);if(index>=0)known[index]=hasher;else known.push(hasher)},Password.hasher=()=>defaultHasher(),Password.hash=(password)=>defaultHasher().hash(password);let hasherOf=(hash)=>{let current=defaultHasher();if(hash.startsWith(current.prefix))return current;let found=known.find((entry)=>hash.startsWith(entry.prefix));if(found)return found;if(hash.startsWith("$pbkdf2-sha256$"))return new Pbkdf2PasswordHasher;if(hash.startsWith("$argon2")&&bunPassword())return new BunPasswordHasher;return};Password.verify=async(password,hash)=>await hasherOf(hash)?.verify(password,hash)??!1,Password.needsRehash=(hash)=>{let current=defaultHasher();return!hash.startsWith(current.prefix)||current.needsRehash(hash)},Password.reset=()=>{configured=void 0,known.length=0}})(Password||={});import{DomainError as DomainError3,getLogger as getLogger2}from"@elysiumjs/core";import*as oauth from"oauth4webapi";class OAuthCallbackError extends DomainError3.for("OAUTH_CALLBACK_FAILED",400){constructor(reason){super(`The sign-in could not be completed: ${reason}.`)}}class OAuthClient{options;#server;constructor(options){this.options=options;if(!options.issuer&&!options.authorizationServer)throw TypeError("An OAuthClient needs an issuer or an authorizationServer.")}get oidc(){return this.scopes.includes("openid")}get scopes(){return this.options.scopes??["openid","email","profile"]}get sessionKey(){return`_oauth:${this.options.name??"default"}`}get client(){return{client_id:this.options.clientId}}get auth(){return this.options.clientSecret?oauth.ClientSecretPost(this.options.clientSecret):oauth.None()}get requestOptions(){return this.options.fetch?{[oauth.customFetch]:this.options.fetch}:{}}server(){if(this.options.authorizationServer)return Promise.resolve(this.options.authorizationServer);return this.#server??=(async()=>{let issuer=new URL(this.options.issuer),response=await oauth.discoveryRequest(issuer,{algorithm:this.oidc?"oidc":"oauth2",...this.requestOptions});return oauth.processDiscoveryResponse(issuer,response)})(),this.#server.catch(()=>this.#server=void 0),this.#server}async authorize(params={}){let server=await this.server();if(!server.authorization_endpoint)throw TypeError("The provider has no authorization endpoint.");let pending={state:oauth.generateRandomState(),verifier:oauth.generateRandomCodeVerifier(),nonce:this.oidc?oauth.generateRandomNonce():void 0};Session.current().set(this.sessionKey,pending);let url=new URL(server.authorization_endpoint);if(url.searchParams.set("response_type","code"),url.searchParams.set("client_id",this.options.clientId),url.searchParams.set("redirect_uri",this.options.redirectUri),url.searchParams.set("scope",this.scopes.join(" ")),url.searchParams.set("state",pending.state),url.searchParams.set("code_challenge",await oauth.calculatePKCECodeChallenge(pending.verifier)),url.searchParams.set("code_challenge_method","S256"),pending.nonce)url.searchParams.set("nonce",pending.nonce);for(let[key,value]of Object.entries(params))url.searchParams.set(key,value);return url}async callback(currentUrl){let pending=Session.current().pull(this.sessionKey);if(!pending)throw new OAuthCallbackError("no sign-in is in progress");let server=await this.server();try{let params=oauth.validateAuthResponse(server,this.client,new URL(currentUrl),pending.state),response=await oauth.authorizationCodeGrantRequest(server,this.client,this.auth,params,this.options.redirectUri,pending.verifier,this.requestOptions),tokens=await oauth.processAuthorizationCodeResponse(server,this.client,response,{expectedNonce:pending.nonce,requireIdToken:this.oidc});if(!this.oidc)return{tokens};let claims=oauth.getValidatedIdTokenClaims(tokens),userInfo;if(server.userinfo_endpoint&&claims){let info=await oauth.userInfoRequest(server,this.client,tokens.access_token,this.requestOptions);userInfo=await oauth.processUserInfoResponse(server,this.client,claims.sub,info)}return{tokens,claims,userInfo}}catch(error){if(error instanceof DomainError3)throw error;let code=error.error;if(typeof code==="string"&&/^[\w.-]+$/.test(code))throw new OAuthCallbackError(code);throw getLogger2().warn("An OAuth callback failed",{error}),new OAuthCallbackError("the provider could not be reached or answered wrongly")}}}import{DomainError as DomainError4}from"@elysiumjs/core";import{generateAuthenticationOptions,generateRegistrationOptions,verifyAuthenticationResponse,verifyRegistrationResponse}from"@simplewebauthn/server";class PasskeyVerificationError extends DomainError4.for("PASSKEY_VERIFICATION_FAILED",400){constructor(reason){super(`The passkey could not be verified: ${reason}.`)}}var REGISTRATION="_passkey:registration",AUTHENTICATION="_passkey:authentication";class Passkeys{options;constructor(options){this.options=options}async registrationOptions(user){let existing=await this.options.repository.forUser(user.id),options=await generateRegistrationOptions({rpName:this.options.rpName,rpID:this.options.rpID,userName:user.name,userDisplayName:user.displayName??user.name,userID:utf8(user.id),attestationType:"none",excludeCredentials:existing.map((passkey)=>({id:passkey.id,transports:passkey.transports})),authenticatorSelection:{residentKey:"preferred",userVerification:this.options.userVerification??"preferred"}});return Session.current().set(REGISTRATION,{challenge:options.challenge,userId:user.id}),options}async verifyRegistration(response){let pending=Session.current().pull(REGISTRATION);if(!pending)throw new PasskeyVerificationError("no registration is in progress");let result;try{result=await verifyRegistrationResponse({response,expectedChallenge:pending.challenge,expectedOrigin:this.options.origin,expectedRPID:this.options.rpID,requireUserVerification:this.options.userVerification==="required"})}catch(error){throw new PasskeyVerificationError(error.message)}if(!result.verified)throw new PasskeyVerificationError("the response does not verify");let{credential,credentialBackedUp,credentialDeviceType}=result.registrationInfo,record={id:credential.id,userId:pending.userId,publicKey:toBase64Url(credential.publicKey),counter:credential.counter,transports:credential.transports,backedUp:credentialBackedUp,deviceType:credentialDeviceType};return await this.options.repository.save(record),record}async authenticationOptions(user){let allowed=user?await this.options.repository.forUser(user.id):[],options=await generateAuthenticationOptions({rpID:this.options.rpID,userVerification:this.options.userVerification??"preferred",allowCredentials:allowed.map((passkey)=>({id:passkey.id,transports:passkey.transports}))});return Session.current().set(AUTHENTICATION,options.challenge),options}async verifyAuthentication(response){let challenge=Session.current().pull(AUTHENTICATION);if(!challenge)throw new PasskeyVerificationError("no sign-in is in progress");let passkey=await this.options.repository.find(response.id);if(!passkey)throw new PasskeyVerificationError("the passkey is unknown");let result;try{result=await verifyAuthenticationResponse({response,expectedChallenge:challenge,expectedOrigin:this.options.origin,expectedRPID:this.options.rpID,credential:{id:passkey.id,publicKey:fromBase64Url(passkey.publicKey),counter:passkey.counter,transports:passkey.transports},requireUserVerification:this.options.userVerification==="required"})}catch(error){throw new PasskeyVerificationError(error.message)}if(!result.verified)throw new PasskeyVerificationError("the response does not verify");return await this.options.repository.updateCounter(passkey.id,result.authenticationInfo.newCounter),{...passkey,counter:result.authenticationInfo.newCounter}}}import{DomainError as DomainError5}from"@elysiumjs/core";class InvalidSignatureError extends DomainError5.for("INVALID_SIGNATURE",403){constructor(expired){super(expired?"This link has expired.":"This link is not valid.")}}var signedPart=(url)=>{let copy=new URL(url);return copy.searchParams.delete("signature"),copy.searchParams.sort(),`${copy.pathname}?${copy.searchParams.toString()}`},SignedUrl;((SignedUrl)=>(SignedUrl.sign=async(url,options)=>{let target=new URL(url);if(target.searchParams.delete("signature"),options.expiresIn!==void 0)target.searchParams.set("expires",String(Math.floor(Date.now()/1000)+options.expiresIn));return assertStrongSecret(options.secret,"signed URL secret"),target.searchParams.set("signature",await hmac(options.secret,signedPart(target))),target.toString()},SignedUrl.verify=async(url,options)=>{try{return await SignedUrl.assert(url,options),!0}catch{return!1}},SignedUrl.assert=async(url,options)=>{let target=new URL(url),signature=target.searchParams.get("signature");if(!signature||!await verifyHmac(options.secret,signedPart(target),signature))throw new InvalidSignatureError(!1);let expires=target.searchParams.get("expires");if(expires!==null&&Number(expires)*1000<Date.now())throw new InvalidSignatureError(!0)}))(SignedUrl||={});import{Module}from"@elysiumjs/core";class ThemisModule extends Module.configurable({required:["secret"]}){}ThemisModule=__legacyDecorateClassTS([Module.register({services:(config)=>(assertStrongSecret(config.secret,"themis secret"),[{name:THEMIS_CONFIG,global:!0,factory:()=>config}])})],ThemisModule);var BASE32="ABCDEFGHIJKLMNOPQRSTUVWXYZ234567",toBase32=(bytes)=>{let bits=0,value=0,out="";for(let byte of bytes){value=value<<8|byte,bits+=8;while(bits>=5)out+=BASE32[value>>>bits-5&31],bits-=5}if(bits>0)out+=BASE32[value<<5-bits&31];return out},fromBase32=(text)=>{let clean=text.toUpperCase().replace(/[\s=-]/g,""),bits=0,value=0,out=[];for(let char of clean){let index=BASE32.indexOf(char);if(index<0)throw TypeError("The TOTP secret is not base32.");if(value=value<<5|index,bits+=5,bits>=8)out.push(value>>>bits-8&255),bits-=8}return new Uint8Array(out)},hotp=async(secret,counter,options)=>{let message=new Uint8Array(8),rest=counter;for(let i=7;i>=0;i--)message[i]=rest&255,rest=Math.floor(rest/256);let hash=options.algorithm.replace("SHA","SHA-"),key=await crypto.subtle.importKey("raw",fromBase32(secret),{name:"HMAC",hash},!1,["sign"]),mac=new Uint8Array(await crypto.subtle.sign("HMAC",key,message)),offset=mac[mac.length-1]&15,binary=(mac[offset]&127)<<24|mac[offset+1]<<16|mac[offset+2]<<8|mac[offset+3];return String(binary%10**options.digits).padStart(options.digits,"0")},defaults=(options={})=>({digits:options.digits??6,period:options.period??30,algorithm:options.algorithm??"SHA1"}),Totp;((Totp)=>(Totp.generateSecret=(bytes=20)=>toBase32(randomBytes(bytes)),Totp.uri=(options)=>{let{digits,period,algorithm}=defaults(options),label=`${encodeURIComponent(options.issuer)}:${encodeURIComponent(options.account)}`,params=new URLSearchParams({secret:options.secret,issuer:options.issuer,algorithm,digits:String(digits),period:String(period)});return`otpauth://totp/${label}?${params.toString()}`},Totp.generate=(secret,options={})=>{let config=defaults(options);return hotp(secret,Math.floor((options.time??Date.now())/1000/config.period),config)},Totp.verify=async(code,secret,options={})=>{let config=defaults(options),typed=code.replace(/\s/g,"");if(!/^\d+$/.test(typed)||typed.length!==config.digits)return null;let current=Math.floor((options.time??Date.now())/1000/config.period),window=options.window??1,found=null;for(let step=-window;step<=window;step++)if(safeEqual(await hotp(secret,current+step,config),typed)&&found===null)found=current+step;return found}))(Totp||={});var CODE_ALPHABET="23456789abcdefghjkmnpqrstuvwxyz",normalize=(code)=>code.toLowerCase().replace(/[\s-]/g,""),BackupCodes;((BackupCodes)=>(BackupCodes.generate=async(count=10)=>{let codes=Array.from({length:count},()=>{let chars=[...randomBytes(10)].map((byte)=>CODE_ALPHABET[byte%CODE_ALPHABET.length]);return`${chars.slice(0,5).join("")}-${chars.slice(5).join("")}`});return{codes,hashes:await Promise.all(codes.map((code)=>sha256(normalize(code))))}},BackupCodes.consume=async(code,hashes)=>{let hash=await sha256(normalize(code)),index=hashes.findIndex((stored)=>safeEqual(stored,hash));if(index<0)return{valid:!1,remaining:[...hashes]};return{valid:!0,remaining:hashes.filter((_,i)=>i!==index)}}))(BackupCodes||={});export{ApiKeys,Auth,AuthMiddleware,BackupCodes,BunPasswordHasher,Csrf,CsrfMiddleware,CsrfTokenMismatchError,InvalidSignatureError,InvalidTokenError,Jwt,MIN_SECRET_BYTES,MemorySessionStore,MissingThemisConfigError,NoSessionError,OAuthCallbackError,OAuthClient,PasskeyVerificationError,Passkeys,Password,Pbkdf2PasswordHasher,Session,SessionMiddleware,SessionTooLargeError,SignedUrl,THEMIS_CONFIG,ThemisModule,Totp,UnauthenticatedError,UnknownGuardError,WeakSecretError,apiKeyGuard,commitSession,jwtGuard,loadSession,sessionGuard};
package/dist/jwt.d.ts ADDED
@@ -0,0 +1,95 @@
1
+ import type { CryptoKey, JWTPayload, KeyObject } from 'jose';
2
+ /**
3
+ * The key of a token: a shared secret (HS256), or a key of an asymmetric algorithm such as EdDSA
4
+ * or ES256, with its algorithm.
5
+ * @author Axel Nana <axel.nana@workbud.com>
6
+ */
7
+ export type JwtKey = {
8
+ secret: string;
9
+ } | {
10
+ key: CryptoKey | KeyObject | Uint8Array;
11
+ algorithm: string;
12
+ };
13
+ /**
14
+ * The options of a signed token.
15
+ * @author Axel Nana <axel.nana@workbud.com>
16
+ */
17
+ export type JwtSignOptions = JwtKey & {
18
+ /** How long the token lives, such as `15m` or `7d`, or a number of seconds. */
19
+ expiresIn?: string | number;
20
+ issuer?: string;
21
+ audience?: string | string[];
22
+ /** The subject, usually the user id. */
23
+ subject?: string;
24
+ };
25
+ /**
26
+ * What a token must carry to verify.
27
+ * @author Axel Nana <axel.nana@workbud.com>
28
+ */
29
+ export type JwtVerifyOptions = JwtKey & {
30
+ issuer?: string | string[];
31
+ audience?: string | string[];
32
+ /** The clock skew allowed, in seconds. Defaults to 5. */
33
+ clockTolerance?: number;
34
+ };
35
+ declare const InvalidTokenError_base: {
36
+ new (detail?: string, options?: import("@elysiumjs/core").DomainErrorOptions): {
37
+ name: string;
38
+ message: string;
39
+ stack?: string;
40
+ cause?: unknown;
41
+ value?(): import("elysia/types").MaybePromise<unknown>;
42
+ readonly code: "UNAUTHENTICATED";
43
+ readonly status: 401;
44
+ readonly detail: string | undefined;
45
+ readonly title: string;
46
+ readonly errors: readonly import("@elysiumjs/core").ProblemValidationError[] | undefined;
47
+ readonly retryAfterSeconds: number | undefined;
48
+ readonly extensions: Readonly<Record<string, unknown>>;
49
+ readonly headers: Record<string, string> | undefined;
50
+ readonly type: string;
51
+ extension(): Record<string, unknown>;
52
+ };
53
+ isError(error: unknown): error is Error;
54
+ isError(value: unknown): value is Error;
55
+ captureStackTrace(targetObject: object, constructorOpt?: Function): void;
56
+ captureStackTrace(targetObject: object, constructorOpt?: Function): void;
57
+ prepareStackTrace(err: Error, stackTraces: NodeJS.CallSite[]): any;
58
+ stackTraceLimit: number;
59
+ id<const Type extends string>(code: Type): import("elysia").TaggedHTTPError<Type>;
60
+ id<const Type extends string, const S_1 extends number | keyof import("elysia").StatusMap>(code: Type, status: S_1): import("elysia").TaggedHTTPError<Type, {
61
+ readonly status: S_1 extends keyof import("elysia").StatusMap ? import("elysia").StatusMap[S_1] : S_1;
62
+ }>;
63
+ typeBase?: string;
64
+ for<const C extends import("@elysiumjs/core").ProblemCode, const S extends number>(code: C, status: S): any;
65
+ };
66
+ /**
67
+ * Raised when a token does not verify: a bad signature, an expired token, or a wrong issuer or
68
+ * audience. Rendered as a 401 problem.
69
+ * @author Axel Nana <axel.nana@workbud.com>
70
+ */
71
+ export declare class InvalidTokenError extends InvalidTokenError_base {
72
+ constructor(reason: string);
73
+ }
74
+ export declare namespace Jwt {
75
+ /**
76
+ * Signs a JSON Web Token with jose, which runs on WebCrypto, so on every runtime.
77
+ * @author Axel Nana <axel.nana@workbud.com>
78
+ * @param claims The claims of the token.
79
+ * @param options The key, the lifetime and the registered claims.
80
+ * @example
81
+ * ```ts
82
+ * const token = await Jwt.sign({ role: 'admin' }, { secret: env.JWT_SECRET, subject: user.id, expiresIn: '15m' });
83
+ * ```
84
+ */
85
+ const sign: (claims: JWTPayload, options: JwtSignOptions) => Promise<string>;
86
+ /**
87
+ * Verifies a token and returns its claims.
88
+ * @author Axel Nana <axel.nana@workbud.com>
89
+ * @param token The token.
90
+ * @param options The key, and the issuer and audience the token must carry.
91
+ * @throws {InvalidTokenError} When the token does not verify (401).
92
+ */
93
+ const verify: <T extends JWTPayload = JWTPayload>(token: string, options: JwtVerifyOptions) => Promise<T>;
94
+ }
95
+ export {};
@@ -0,0 +1,114 @@
1
+ import type { AuthorizationServer, IDToken, TokenEndpointResponse, UserInfoResponse } from 'oauth4webapi';
2
+ declare const OAuthCallbackError_base: {
3
+ new (detail?: string, options?: import("@elysiumjs/core").DomainErrorOptions): {
4
+ name: string;
5
+ message: string;
6
+ stack?: string;
7
+ cause?: unknown;
8
+ value?(): import("elysia/types").MaybePromise<unknown>;
9
+ readonly code: "OAUTH_CALLBACK_FAILED";
10
+ readonly status: 400;
11
+ readonly detail: string | undefined;
12
+ readonly title: string;
13
+ readonly errors: readonly import("@elysiumjs/core").ProblemValidationError[] | undefined;
14
+ readonly retryAfterSeconds: number | undefined;
15
+ readonly extensions: Readonly<Record<string, unknown>>;
16
+ readonly headers: Record<string, string> | undefined;
17
+ readonly type: string;
18
+ extension(): Record<string, unknown>;
19
+ };
20
+ isError(error: unknown): error is Error;
21
+ isError(value: unknown): value is Error;
22
+ captureStackTrace(targetObject: object, constructorOpt?: Function): void;
23
+ captureStackTrace(targetObject: object, constructorOpt?: Function): void;
24
+ prepareStackTrace(err: Error, stackTraces: NodeJS.CallSite[]): any;
25
+ stackTraceLimit: number;
26
+ id<const Type extends string>(code: Type): import("elysia").TaggedHTTPError<Type>;
27
+ id<const Type extends string, const S_1 extends number | keyof import("elysia").StatusMap>(code: Type, status: S_1): import("elysia").TaggedHTTPError<Type, {
28
+ readonly status: S_1 extends keyof import("elysia").StatusMap ? import("elysia").StatusMap[S_1] : S_1;
29
+ }>;
30
+ typeBase?: string;
31
+ for<const C extends import("@elysiumjs/core").ProblemCode, const S extends number>(code: C, status: S): any;
32
+ };
33
+ /**
34
+ * Raised when an OAuth callback does not check out: a wrong state, an error from the provider, or
35
+ * a failed code exchange. Rendered as a 400 problem.
36
+ * @author Axel Nana <axel.nana@workbud.com>
37
+ */
38
+ export declare class OAuthCallbackError extends OAuthCallbackError_base {
39
+ constructor(reason: string);
40
+ }
41
+ /**
42
+ * The options of an OAuth 2.0 / OpenID Connect client.
43
+ * @author Axel Nana <axel.nana@workbud.com>
44
+ */
45
+ export type OAuthClientOptions = {
46
+ /** The name of the client, when the app has several providers. Defaults to `default`. */
47
+ name?: string;
48
+ /** The issuer, whose metadata is discovered (`/.well-known/openid-configuration`). */
49
+ issuer?: string;
50
+ /** The endpoints of a provider without discovery, such as GitHub. */
51
+ authorizationServer?: AuthorizationServer;
52
+ clientId: string;
53
+ /** The client secret, sent in the token request. Omit it for a public client. */
54
+ clientSecret?: string;
55
+ /** The callback URL registered with the provider. */
56
+ redirectUri: string;
57
+ /** The scopes. Defaults to `openid email profile`. */
58
+ scopes?: string[];
59
+ /** A fetch for the provider's endpoints, such as a test double. */
60
+ fetch?: typeof fetch;
61
+ };
62
+ /**
63
+ * What a successful callback gives: the tokens, and for OpenID Connect the claims of the ID token
64
+ * and the user info.
65
+ * @author Axel Nana <axel.nana@workbud.com>
66
+ */
67
+ export type OAuthResult = {
68
+ tokens: TokenEndpointResponse;
69
+ claims?: IDToken;
70
+ userInfo?: UserInfoResponse;
71
+ };
72
+ /**
73
+ * Signs users in with an OAuth 2.0 or OpenID Connect provider, with the authorization code flow
74
+ * and PKCE, through oauth4webapi. The state, the PKCE verifier and the nonce live in the session.
75
+ * @author Axel Nana <axel.nana@workbud.com>
76
+ * @example
77
+ * ```ts
78
+ * const google = new OAuthClient({
79
+ * issuer: 'https://accounts.google.com',
80
+ * clientId: env.GOOGLE_CLIENT_ID,
81
+ * clientSecret: env.GOOGLE_CLIENT_SECRET,
82
+ * redirectUri: 'https://api.example.com/auth/google/callback'
83
+ * });
84
+ *
85
+ * // GET /auth/google: redirect to (await google.authorize()).
86
+ * // GET /auth/google/callback: const { claims } = await google.callback(request.url);
87
+ * ```
88
+ */
89
+ export declare class OAuthClient {
90
+ #private;
91
+ private readonly options;
92
+ constructor(options: OAuthClientOptions);
93
+ private get oidc();
94
+ private get scopes();
95
+ private get sessionKey();
96
+ private get client();
97
+ private get auth();
98
+ private get requestOptions();
99
+ /** The provider's metadata: given, or discovered once. */
100
+ server(): Promise<AuthorizationServer>;
101
+ /**
102
+ * The URL to send the user to, with a new state, PKCE challenge and nonce kept in the session.
103
+ * @param params Extra parameters, such as `prompt` or `login_hint`.
104
+ */
105
+ authorize(params?: Record<string, string>): Promise<URL>;
106
+ /**
107
+ * Completes the sign-in on the callback: checks the state, exchanges the code, and validates
108
+ * the ID token. The pending state is used up, whatever the outcome.
109
+ * @param currentUrl The URL of the callback request, with its query.
110
+ * @throws {OAuthCallbackError} When the callback does not check out (400).
111
+ */
112
+ callback(currentUrl: string | URL): Promise<OAuthResult>;
113
+ }
114
+ export {};
@@ -0,0 +1,115 @@
1
+ import type { AuthenticationResponseJSON, PublicKeyCredentialCreationOptionsJSON, PublicKeyCredentialRequestOptionsJSON, RegistrationResponseJSON } from '@simplewebauthn/server';
2
+ /**
3
+ * A passkey the app keeps for a user.
4
+ * @author Axel Nana <axel.nana@workbud.com>
5
+ */
6
+ export type PasskeyRecord = {
7
+ /** The credential id, base64url. */
8
+ id: string;
9
+ userId: string;
10
+ /** The COSE public key, base64url. */
11
+ publicKey: string;
12
+ /** The signature counter, which must grow (0 for authenticators that do not count). */
13
+ counter: number;
14
+ transports?: string[];
15
+ /** Whether the passkey syncs between devices. */
16
+ backedUp: boolean;
17
+ deviceType: 'singleDevice' | 'multiDevice';
18
+ };
19
+ /**
20
+ * Where the app keeps passkeys.
21
+ * @author Axel Nana <axel.nana@workbud.com>
22
+ */
23
+ export interface PasskeyRepository {
24
+ forUser(userId: string): Promise<PasskeyRecord[]>;
25
+ find(id: string): Promise<PasskeyRecord | undefined>;
26
+ save(record: PasskeyRecord): Promise<void>;
27
+ updateCounter(id: string, counter: number): Promise<void>;
28
+ }
29
+ declare const PasskeyVerificationError_base: {
30
+ new (detail?: string, options?: import("@elysiumjs/core").DomainErrorOptions): {
31
+ name: string;
32
+ message: string;
33
+ stack?: string;
34
+ cause?: unknown;
35
+ value?(): import("elysia/types").MaybePromise<unknown>;
36
+ readonly code: "PASSKEY_VERIFICATION_FAILED";
37
+ readonly status: 400;
38
+ readonly detail: string | undefined;
39
+ readonly title: string;
40
+ readonly errors: readonly import("@elysiumjs/core").ProblemValidationError[] | undefined;
41
+ readonly retryAfterSeconds: number | undefined;
42
+ readonly extensions: Readonly<Record<string, unknown>>;
43
+ readonly headers: Record<string, string> | undefined;
44
+ readonly type: string;
45
+ extension(): Record<string, unknown>;
46
+ };
47
+ isError(error: unknown): error is Error;
48
+ isError(value: unknown): value is Error;
49
+ captureStackTrace(targetObject: object, constructorOpt?: Function): void;
50
+ captureStackTrace(targetObject: object, constructorOpt?: Function): void;
51
+ prepareStackTrace(err: Error, stackTraces: NodeJS.CallSite[]): any;
52
+ stackTraceLimit: number;
53
+ id<const Type extends string>(code: Type): import("elysia").TaggedHTTPError<Type>;
54
+ id<const Type extends string, const S_1 extends number | keyof import("elysia").StatusMap>(code: Type, status: S_1): import("elysia").TaggedHTTPError<Type, {
55
+ readonly status: S_1 extends keyof import("elysia").StatusMap ? import("elysia").StatusMap[S_1] : S_1;
56
+ }>;
57
+ typeBase?: string;
58
+ for<const C extends import("@elysiumjs/core").ProblemCode, const S extends number>(code: C, status: S): any;
59
+ };
60
+ /**
61
+ * Raised when a passkey registration or sign-in does not verify. Rendered as a 400 problem.
62
+ * @author Axel Nana <axel.nana@workbud.com>
63
+ */
64
+ export declare class PasskeyVerificationError extends PasskeyVerificationError_base {
65
+ constructor(reason: string);
66
+ }
67
+ /**
68
+ * The options of {@link Passkeys}.
69
+ * @author Axel Nana <axel.nana@workbud.com>
70
+ */
71
+ export type PasskeysOptions = {
72
+ /** The name of the app, shown by the authenticator. */
73
+ rpName: string;
74
+ /** The relying party id: the domain, such as `example.com`. */
75
+ rpID: string;
76
+ /** The origins the browser may call from, such as `https://example.com`. */
77
+ origin: string | string[];
78
+ repository: PasskeyRepository;
79
+ /** Defaults to `preferred`. */
80
+ userVerification?: 'required' | 'preferred' | 'discouraged';
81
+ };
82
+ /**
83
+ * Registers passkeys and signs users in with them (WebAuthn), through @simplewebauthn/server. The
84
+ * challenges live in the session; the passkeys in the app's {@link PasskeyRepository}.
85
+ * @author Axel Nana <axel.nana@workbud.com>
86
+ */
87
+ export declare class Passkeys {
88
+ private readonly options;
89
+ constructor(options: PasskeysOptions);
90
+ /** The options of a new passkey of a user, for `navigator.credentials.create()`. */
91
+ registrationOptions(user: {
92
+ id: string;
93
+ name: string;
94
+ displayName?: string;
95
+ }): Promise<PublicKeyCredentialCreationOptionsJSON>;
96
+ /**
97
+ * Verifies the answer of `navigator.credentials.create()` and saves the new passkey.
98
+ * @throws {PasskeyVerificationError} When it does not verify (400).
99
+ */
100
+ verifyRegistration(response: RegistrationResponseJSON): Promise<PasskeyRecord>;
101
+ /**
102
+ * The options of a sign-in, for `navigator.credentials.get()`. Without a user, any passkey of
103
+ * the site may answer (usernameless sign-in).
104
+ */
105
+ authenticationOptions(user?: {
106
+ id: string;
107
+ }): Promise<PublicKeyCredentialRequestOptionsJSON>;
108
+ /**
109
+ * Verifies the answer of `navigator.credentials.get()`, updates the counter, and returns the
110
+ * passkey, whose `userId` is the user to sign in.
111
+ * @throws {PasskeyVerificationError} When it does not verify (400).
112
+ */
113
+ verifyAuthentication(response: AuthenticationResponseJSON): Promise<PasskeyRecord>;
114
+ }
115
+ export {};
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Hashes and verifies passwords. A hash is a self-describing string (`$argon2id$...`,
3
+ * `$pbkdf2-sha256$...`, `$scrypt$...`), so that a hasher recognises its own hashes.
4
+ * @author Axel Nana <axel.nana@workbud.com>
5
+ */
6
+ export interface PasswordHasher {
7
+ /** The prefix of the hashes this hasher makes, such as `$pbkdf2-sha256$`. */
8
+ readonly prefix: string;
9
+ hash(password: string): Promise<string>;
10
+ verify(password: string, hash: string): Promise<boolean>;
11
+ /** Whether a hash of this hasher was made with weaker parameters than the current ones. */
12
+ needsRehash(hash: string): boolean;
13
+ }
14
+ /**
15
+ * Hashes passwords with PBKDF2-HMAC-SHA256 through WebCrypto, which every runtime has. The
16
+ * iterations default to 600,000, and to 100,000 on Cloudflare Workers, which cap PBKDF2 there.
17
+ * @author Axel Nana <axel.nana@workbud.com>
18
+ */
19
+ export declare class Pbkdf2PasswordHasher implements PasswordHasher {
20
+ private readonly options;
21
+ readonly prefix = "$pbkdf2-sha256$";
22
+ constructor(options?: {
23
+ iterations?: number;
24
+ });
25
+ private get iterations();
26
+ hash(password: string): Promise<string>;
27
+ verify(password: string, hash: string): Promise<boolean>;
28
+ needsRehash(hash: string): boolean;
29
+ private parse;
30
+ private derive;
31
+ }
32
+ /**
33
+ * Hashes passwords with Argon2id through `Bun.password`. Only available on Bun.
34
+ * @author Axel Nana <axel.nana@workbud.com>
35
+ */
36
+ export declare class BunPasswordHasher implements PasswordHasher {
37
+ private readonly options;
38
+ readonly prefix = "$argon2id$";
39
+ constructor(options?: {
40
+ memoryCost?: number;
41
+ timeCost?: number;
42
+ });
43
+ private get api();
44
+ hash(password: string): Promise<string>;
45
+ verify(password: string, hash: string): Promise<boolean>;
46
+ needsRehash(hash: string): boolean;
47
+ }
48
+ export declare namespace Password {
49
+ /**
50
+ * Sets the hasher that makes new hashes. Hashes of the other known hashers still verify, and
51
+ * `needsRehash()` tells when to replace them.
52
+ * @author Axel Nana <axel.nana@workbud.com>
53
+ * @param hasher The hasher.
54
+ */
55
+ const use: (hasher: PasswordHasher) => void;
56
+ /**
57
+ * Adds a hasher that `verify()` recognises by its prefix, such as the one of older hashes.
58
+ * @author Axel Nana <axel.nana@workbud.com>
59
+ */
60
+ const register: (hasher: PasswordHasher) => void;
61
+ /** The hasher that makes new hashes. */
62
+ const hasher: () => PasswordHasher;
63
+ /**
64
+ * Hashes a password with the current hasher.
65
+ * @author Axel Nana <axel.nana@workbud.com>
66
+ */
67
+ const hash: (password: string) => Promise<string>;
68
+ /**
69
+ * Verifies a password against a hash of any known hasher. An unknown or malformed hash does
70
+ * not verify.
71
+ * @author Axel Nana <axel.nana@workbud.com>
72
+ */
73
+ const verify: (password: string, hash: string) => Promise<boolean>;
74
+ /**
75
+ * Whether a hash should be replaced by a new one, after a successful login: it comes from
76
+ * another hasher, or from weaker parameters.
77
+ * @author Axel Nana <axel.nana@workbud.com>
78
+ */
79
+ const needsRehash: (hash: string) => boolean;
80
+ /** Forgets the configured and registered hashers. */
81
+ const reset: () => void;
82
+ }
@@ -0,0 +1,21 @@
1
+ import type { PasswordHasher } from './password';
2
+ type ScryptOptions = {
3
+ N?: number;
4
+ r?: number;
5
+ p?: number;
6
+ keyLength?: number;
7
+ };
8
+ /**
9
+ * Hashes passwords with scrypt from `node:crypto`: Node.js, Bun, and Workers with the
10
+ * `nodejs_compat` flag.
11
+ * @author Axel Nana <axel.nana@workbud.com>
12
+ */
13
+ export declare class ScryptPasswordHasher implements PasswordHasher {
14
+ #private;
15
+ readonly prefix = "$scrypt$";
16
+ constructor(options?: ScryptOptions);
17
+ hash(password: string): Promise<string>;
18
+ verify(password: string, hash: string): Promise<boolean>;
19
+ needsRehash(hash: string): boolean;
20
+ }
21
+ export {};
package/dist/scrypt.js ADDED
@@ -0,0 +1 @@
1
+ import{randomBytes,scrypt,timingSafeEqual}from"node:crypto";var derive=(password,salt,options)=>new Promise((resolve,reject)=>scrypt(password,salt,options.keyLength,{N:options.N,r:options.r,p:options.p,maxmem:256*options.N*options.r},(error,key)=>error?reject(error):resolve(key)));class ScryptPasswordHasher{prefix="$scrypt$";#options;constructor(options={}){this.#options={N:16384,r:8,p:1,keyLength:32,...options}}async hash(password){let salt=randomBytes(16),key=await derive(password,salt,this.#options),{N,r,p}=this.#options;return`${this.prefix}N=${N},r=${r},p=${p}$${salt.toString("base64url")}$${key.toString("base64url")}`}async verify(password,hash){let parsed=this.#parse(hash);if(!parsed)return!1;let key=await derive(password,parsed.salt,{...parsed.options,keyLength:parsed.key.length});return key.length===parsed.key.length&&timingSafeEqual(key,parsed.key)}needsRehash(hash){let parsed=this.#parse(hash);return!parsed||parsed.options.N<this.#options.N||parsed.options.r<this.#options.r}#parse(hash){let match=/^\$scrypt\$N=(\d+),r=(\d+),p=(\d+)\$([\w-]+)\$([\w-]+)$/.exec(hash);if(!match)return null;return{options:{N:Number(match[1]),r:Number(match[2]),p:Number(match[3]),keyLength:32},salt:Buffer.from(match[4],"base64url"),key:Buffer.from(match[5],"base64url")}}}export{ScryptPasswordHasher};
@@ -0,0 +1,98 @@
1
+ import type { HttpContext } from './context';
2
+ import { Middleware } from '@elysiumjs/core';
3
+ /** The data of a session. */
4
+ export type SessionData = Record<string, unknown>;
5
+ /**
6
+ * Where server-side sessions live, by id.
7
+ * @author Axel Nana <axel.nana@workbud.com>
8
+ */
9
+ export interface SessionStore {
10
+ load(id: string): Promise<SessionData | null>;
11
+ save(id: string, data: SessionData, ttlSeconds: number): Promise<void>;
12
+ destroy(id: string): Promise<void>;
13
+ }
14
+ /**
15
+ * Keeps sessions in the memory of the process, for tests and single-process apps.
16
+ * @author Axel Nana <axel.nana@workbud.com>
17
+ */
18
+ export declare class MemorySessionStore implements SessionStore {
19
+ #private;
20
+ load(id: string): Promise<SessionData | null>;
21
+ save(id: string, data: SessionData, ttlSeconds: number): Promise<void>;
22
+ destroy(id: string): Promise<void>;
23
+ }
24
+ /**
25
+ * The session of a request: data kept between the requests of one client, behind a cookie. Read
26
+ * it with `Session.current()` in handlers and services; SessionMiddleware loads it and saves it
27
+ * once the handler has run.
28
+ * @author Axel Nana <axel.nana@workbud.com>
29
+ */
30
+ export declare class Session {
31
+ #private;
32
+ id: string;
33
+ /** Whether the client sent no valid session. */
34
+ readonly isNew: boolean;
35
+ constructor(id: string, data: SessionData,
36
+ /** Whether the client sent no valid session. */
37
+ isNew: boolean);
38
+ /**
39
+ * The session of the current request.
40
+ * @throws {NoSessionError} Outside a request, or when no SessionMiddleware loaded it.
41
+ */
42
+ static current(): Session;
43
+ /** A value, or a flashed value of the previous request. */
44
+ get<T = unknown>(key: string): T | undefined;
45
+ has(key: string): boolean;
46
+ set(key: string, value: unknown): void;
47
+ forget(key: string): void;
48
+ /** A value and its removal. */
49
+ pull<T = unknown>(key: string): T | undefined;
50
+ /** Keeps a value for the next request only, such as a message after a redirect. */
51
+ flash(key: string, value: unknown): void;
52
+ /** Every value. */
53
+ all(): SessionData;
54
+ /** Gives the session a new id, keeping its data. Call it at login, against session fixation. */
55
+ regenerate(): void;
56
+ /** Ends the session: its data is deleted and the cookie cleared. */
57
+ destroy(): void;
58
+ /** @internal What the middleware saves. */
59
+ get state(): {
60
+ data: SessionData;
61
+ dirty: boolean;
62
+ destroyed: boolean;
63
+ previousId: string | null;
64
+ };
65
+ }
66
+ /**
67
+ * Loads the session of a request once, from its cookie. A cookie that does not verify gives a new
68
+ * session.
69
+ * @author Axel Nana <axel.nana@workbud.com>
70
+ */
71
+ export declare const loadSession: (context: HttpContext) => Promise<Session>;
72
+ /**
73
+ * Saves the session of a request, and sets or clears its cookie.
74
+ * @author Axel Nana <axel.nana@workbud.com>
75
+ */
76
+ export declare const commitSession: (context: HttpContext) => Promise<void>;
77
+ /**
78
+ * Loads the session before the handler, and saves it after, also when the request ends with a
79
+ * client error. Register it on the application, or on the controllers that use sessions.
80
+ * @author Axel Nana <axel.nana@workbud.com>
81
+ * @example
82
+ * ```ts
83
+ * @Application.register({ modules: [ThemisModule.with({ secret: env.APP_SECRET })] })
84
+ * @Middleware.register(SessionMiddleware)
85
+ * class App extends Application {}
86
+ * ```
87
+ */
88
+ export declare class SessionMiddleware extends Middleware {
89
+ onBeforeHandle(context: HttpContext): Promise<void>;
90
+ onAfterHandle(context: HttpContext): Promise<void>;
91
+ /**
92
+ * Saves the session of a request refused with a client error too, such as a sign-in followed by
93
+ * a thrown problem. A server error keeps the session as it was.
94
+ */
95
+ onError(context: HttpContext & {
96
+ error: unknown;
97
+ }): Promise<void>;
98
+ }