@elysiumjs/themis 0.1.0 → 0.2.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/CHANGELOG.md +18 -0
- package/README.md +20 -0
- package/dist/config.d.ts +4 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -1
- package/dist/jwt.d.ts +31 -5
- package/dist/secret.d.ts +14 -0
- package/dist/signed-url.d.ts +4 -3
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# @elysiumjs/themis
|
|
2
2
|
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#34](https://github.com/na2axl/elysiumjs/pull/34) [`89fb829`](https://github.com/na2axl/elysiumjs/commit/89fb8296d6894f240513f6224093292af4046635) Thanks [@na2axl](https://github.com/na2axl)! - Secrets can be functions, and `Jwt` gains key rotation, a required expiry and a clock.
|
|
8
|
+
|
|
9
|
+
- The `ThemisModule` secret, JWT secrets and signed URL secrets accept a function (sync or async) called each time the secret is used, such as one reading a Workers secrets binding during the request.
|
|
10
|
+
- `Jwt.verify` takes `keys`, tried in order until one verifies the signature.
|
|
11
|
+
- Breaking: `Jwt.verify` refuses a token without `exp` by default. `requiredClaims` sets the claims a token must carry (`[]` accepts one without `exp`).
|
|
12
|
+
- `Jwt.verify` takes `currentDate`, and `Jwt.sign` takes `issuedAt`, which `expiresIn` counts from.
|
|
13
|
+
|
|
14
|
+
## 0.1.1
|
|
15
|
+
|
|
16
|
+
### Patch Changes
|
|
17
|
+
|
|
18
|
+
- Updated dependencies [[`651a755`](https://github.com/na2axl/elysiumjs/commit/651a7557173ce2c6155bcfa8d90cf388ac419821)]:
|
|
19
|
+
- @elysiumjs/core@0.12.0
|
|
20
|
+
|
|
3
21
|
## 0.1.0
|
|
4
22
|
|
|
5
23
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -125,6 +125,26 @@ await SignedUrl.verify(link, { secret });
|
|
|
125
125
|
|
|
126
126
|
`Jwt` signs with a secret (HS256) or with an asymmetric key and its algorithm. An invalid or expired token is a 401 problem, and an invalid or expired link a 403 problem.
|
|
127
127
|
|
|
128
|
+
`Jwt.verify` refuses a token without an expiry; pass `requiredClaims` to change the claims a token must carry (`[]` accepts one without `exp`). `currentDate` checks the expiry against another time, and `issuedAt` signs as of another time.
|
|
129
|
+
|
|
130
|
+
### Secrets read when they are used
|
|
131
|
+
|
|
132
|
+
Every secret (the `ThemisModule` secret, JWT secrets and signed URL secrets) can be a function, sync or async, called each time the secret is needed. Read it from a store bound to the request, such as a secrets binding on Cloudflare Workers, and a rotated secret is picked up without a restart. The 32-byte minimum is checked on each read.
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
ThemisModule.with({ secret: () => secrets.get('app-secret') });
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
During a rotation, verify with the current key and then the previous one:
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
const claims = await Jwt.verify(token, {
|
|
142
|
+
keys: [{ secret: () => secrets.get('jwt-current') }, { secret: () => secrets.get('jwt-previous') }]
|
|
143
|
+
});
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
The keys are tried in order until one verifies the signature; an expired token or a wrong claim is refused at once.
|
|
147
|
+
|
|
128
148
|
## Two-factor codes
|
|
129
149
|
|
|
130
150
|
```ts
|
package/dist/config.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Guard, GuardClass } from './auth';
|
|
2
2
|
import type { CookieOptions } from './context';
|
|
3
|
+
import type { Secret } from './secret';
|
|
3
4
|
import type { SessionStore } from './session';
|
|
4
5
|
/** The service name of the Themis configuration. */
|
|
5
6
|
export declare const THEMIS_CONFIG = "themis.settings";
|
|
@@ -10,9 +11,10 @@ export declare const THEMIS_CONFIG = "themis.settings";
|
|
|
10
11
|
export type ThemisConfig = {
|
|
11
12
|
/**
|
|
12
13
|
* The secret that signs the session cookie, encrypts cookie sessions and binds CSRF tokens. At
|
|
13
|
-
* least 32 random characters, kept out of the code (an environment secret).
|
|
14
|
+
* least 32 random characters, kept out of the code (an environment secret). A function is called
|
|
15
|
+
* each time the secret is needed, so it can read it from a store bound to the request.
|
|
14
16
|
*/
|
|
15
|
-
secret:
|
|
17
|
+
secret: Secret;
|
|
16
18
|
/** The sessions. */
|
|
17
19
|
session?: {
|
|
18
20
|
/** Where sessions live. `cookie` keeps the whole session in an encrypted cookie. Defaults to memory. */
|
package/dist/index.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ export { BunPasswordHasher, Password, type PasswordHasher, Pbkdf2PasswordHasher
|
|
|
9
9
|
export { commitSession, loadSession, MemorySessionStore, Session, type SessionData, SessionMiddleware, type SessionStore } from './session';
|
|
10
10
|
export { OAuthCallbackError, OAuthClient, type OAuthClientOptions, type OAuthResult } from './oauth';
|
|
11
11
|
export { type PasskeyRecord, type PasskeyRepository, Passkeys, type PasskeysOptions, PasskeyVerificationError } from './passkeys';
|
|
12
|
+
export { type Secret } from './secret';
|
|
12
13
|
export { InvalidSignatureError, SignedUrl } from './signed-url';
|
|
13
14
|
export { ThemisModule } from './themis.module';
|
|
14
15
|
export { BackupCodes, Totp, type TotpOptions } from './totp';
|
package/dist/index.js
CHANGED
|
@@ -1 +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&¤tEnvironment()!=="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};
|
|
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";var resolveSecret=async(secret,what)=>{let value=typeof secret==="function"?await secret():secret;return assertStrongSecret(value,what),value};class InvalidTokenError extends DomainError2.for("UNAUTHENTICATED",401){constructor(reason){super(`The token is invalid: ${reason}.`)}}var keyOf=async(key)=>{if(!("secret"in key))return{key:key.key,algorithm:key.algorithm};return{key:utf8(await resolveSecret(key.secret,"JWT secret")),algorithm:"HS256"}},UNIT_SECONDS={s:1,m:60,h:3600,d:86400,w:604800},secondsOf=(lifetime)=>{let match=/^(\d+)\s*([smhdw])$/.exec(lifetime.trim());if(!match)throw TypeError(`Invalid token lifetime "${lifetime}": use a number of seconds or a value such as 15m, 12h or 7d.`);return Number(match[1])*UNIT_SECONDS[match[2]]},keysOf=(options)=>("keys"in options)?options.keys:[options],otherKeyMayVerify=(error)=>{let code=error.code;return code==="ERR_JWS_SIGNATURE_VERIFICATION_FAILED"||code==="ERR_JOSE_ALG_NOT_ALLOWED"},reasonOf=(error)=>{let code=error.code;return code==="ERR_JWT_EXPIRED"?"it has expired":code==="ERR_JWT_CLAIM_VALIDATION_FAILED"?error.message:"it does not verify"},Jwt;((Jwt)=>(Jwt.sign=async(claims,options)=>{let{key,algorithm}=await keyOf(options),issuedAt=Math.floor((options.issuedAt?.getTime()??Date.now())/1000),jwt=new SignJWT(claims).setProtectedHeader({alg:algorithm,typ:"JWT"}).setIssuedAt(issuedAt);if(options.expiresIn!==void 0){let{expiresIn}=options;jwt=jwt.setExpirationTime(typeof expiresIn==="number"?issuedAt+expiresIn:options.issuedAt?issuedAt+secondsOf(expiresIn):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 keys=keysOf(options);if(keys.length===0)throw new InvalidTokenError("no key verifies it");let failure;for(let candidate of keys){let{key,algorithm}=await keyOf(candidate);try{let{payload}=await jwtVerify(token,key,{algorithms:[algorithm],issuer:options.issuer,audience:options.audience,clockTolerance:options.clockTolerance??5,requiredClaims:[...options.requiredClaims??["exp"]],currentDate:options.currentDate});return payload}catch(error){if(failure=error,!otherKeyMayVerify(error))break}}throw new InvalidTokenError(reasonOf(failure))}))(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 SECRET="themis secret",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&¤tEnvironment()!=="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(await resolveSecret(config.secret,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),secret=await resolveSecret(config.secret,SECRET);if(dot>0&&await verifyHmac(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;let secret=await resolveSecret(config.secret,SECRET);if(store==="cookie"){let sealed=await encrypt(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(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));let secret=await resolveSecret(options.secret,"signed URL secret");return target.searchParams.set("signature",await hmac(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"),secret=await resolveSecret(options.secret,"signed URL secret");if(!signature||!await verifyHmac(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)=>{if(typeof config.secret==="string")assertStrongSecret(config.secret,"themis secret");return[{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
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import type { CryptoKey, JWTPayload, KeyObject } from 'jose';
|
|
2
|
+
import type { Secret } from './secret';
|
|
2
3
|
/**
|
|
3
4
|
* 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
|
+
* or ES256, with its algorithm. A secret can be a function that reads it when the token is signed
|
|
6
|
+
* or verified, such as from a secrets store bound to the request.
|
|
5
7
|
* @author Axel Nana <axel.nana@workbud.com>
|
|
6
8
|
*/
|
|
7
9
|
export type JwtKey = {
|
|
8
|
-
secret:
|
|
10
|
+
secret: Secret;
|
|
9
11
|
} | {
|
|
10
12
|
key: CryptoKey | KeyObject | Uint8Array;
|
|
11
13
|
algorithm: string;
|
|
@@ -21,16 +23,30 @@ export type JwtSignOptions = JwtKey & {
|
|
|
21
23
|
audience?: string | string[];
|
|
22
24
|
/** The subject, usually the user id. */
|
|
23
25
|
subject?: string;
|
|
26
|
+
/**
|
|
27
|
+
* When the token is issued, which `expiresIn` counts from. Defaults to now. With it, a string
|
|
28
|
+
* `expiresIn` takes the units s, m, h, d and w, such as `15m`.
|
|
29
|
+
*/
|
|
30
|
+
issuedAt?: Date;
|
|
24
31
|
};
|
|
25
32
|
/**
|
|
26
33
|
* What a token must carry to verify.
|
|
27
34
|
* @author Axel Nana <axel.nana@workbud.com>
|
|
28
35
|
*/
|
|
29
|
-
export type JwtVerifyOptions = JwtKey
|
|
36
|
+
export type JwtVerifyOptions = (JwtKey | {
|
|
37
|
+
keys: readonly JwtKey[];
|
|
38
|
+
}) & {
|
|
30
39
|
issuer?: string | string[];
|
|
31
40
|
audience?: string | string[];
|
|
32
41
|
/** The clock skew allowed, in seconds. Defaults to 5. */
|
|
33
42
|
clockTolerance?: number;
|
|
43
|
+
/**
|
|
44
|
+
* The claims a token must carry. Defaults to `['exp']`, so that a token without an expiry,
|
|
45
|
+
* which would never expire, is refused; pass `[]` to accept one.
|
|
46
|
+
*/
|
|
47
|
+
requiredClaims?: readonly string[];
|
|
48
|
+
/** The time the token's expiry and not-before are checked against. Defaults to now. */
|
|
49
|
+
currentDate?: Date;
|
|
34
50
|
};
|
|
35
51
|
declare const InvalidTokenError_base: {
|
|
36
52
|
new (detail?: string, options?: import("@elysiumjs/core").DomainErrorOptions): {
|
|
@@ -84,10 +100,20 @@ export declare namespace Jwt {
|
|
|
84
100
|
*/
|
|
85
101
|
const sign: (claims: JWTPayload, options: JwtSignOptions) => Promise<string>;
|
|
86
102
|
/**
|
|
87
|
-
* Verifies a token and returns its claims.
|
|
103
|
+
* Verifies a token and returns its claims. With `keys`, each key is tried in order until one
|
|
104
|
+
* verifies the signature, so that tokens signed with the previous key still verify during a
|
|
105
|
+
* rotation.
|
|
88
106
|
* @author Axel Nana <axel.nana@workbud.com>
|
|
89
107
|
* @param token The token.
|
|
90
|
-
* @param options The key, and the
|
|
108
|
+
* @param options The key or keys, and the claims the token must carry.
|
|
109
|
+
* @example
|
|
110
|
+
* ```ts
|
|
111
|
+
* const claims = await Jwt.verify(token, {
|
|
112
|
+
* keys: [{ secret: () => secrets.get('jwt-current') }, { secret: () => secrets.get('jwt-previous') }],
|
|
113
|
+
* issuer: 'api',
|
|
114
|
+
* requiredClaims: ['exp', 'sub']
|
|
115
|
+
* });
|
|
116
|
+
* ```
|
|
91
117
|
* @throws {InvalidTokenError} When the token does not verify (401).
|
|
92
118
|
*/
|
|
93
119
|
const verify: <T extends JWTPayload = JWTPayload>(token: string, options: JwtVerifyOptions) => Promise<T>;
|
package/dist/secret.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A secret: its value, or a function that reads it when it is needed, such as from a secrets
|
|
3
|
+
* store bound to the request. The function runs on every use, so that a rotated secret is picked
|
|
4
|
+
* up; it caches the value itself when reading it is costly.
|
|
5
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
6
|
+
*/
|
|
7
|
+
export type Secret = string | (() => string | Promise<string>);
|
|
8
|
+
/**
|
|
9
|
+
* The value of a secret, checked to be at least 32 bytes long.
|
|
10
|
+
* @param secret The secret, or the function that reads it.
|
|
11
|
+
* @param what What the secret is for, in the error message.
|
|
12
|
+
* @throws {WeakSecretError} When the secret is too short.
|
|
13
|
+
*/
|
|
14
|
+
export declare const resolveSecret: (secret: Secret, what: string) => Promise<string>;
|
package/dist/signed-url.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Secret } from './secret';
|
|
1
2
|
declare const InvalidSignatureError_base: {
|
|
2
3
|
new (detail?: string, options?: import("@elysiumjs/core").DomainErrorOptions): {
|
|
3
4
|
name: string;
|
|
@@ -50,7 +51,7 @@ export declare namespace SignedUrl {
|
|
|
50
51
|
* ```
|
|
51
52
|
*/
|
|
52
53
|
const sign: (url: string | URL, options: {
|
|
53
|
-
secret:
|
|
54
|
+
secret: Secret;
|
|
54
55
|
expiresIn?: number;
|
|
55
56
|
}) => Promise<string>;
|
|
56
57
|
/**
|
|
@@ -58,7 +59,7 @@ export declare namespace SignedUrl {
|
|
|
58
59
|
* @author Axel Nana <axel.nana@workbud.com>
|
|
59
60
|
*/
|
|
60
61
|
const verify: (url: string | URL, options: {
|
|
61
|
-
secret:
|
|
62
|
+
secret: Secret;
|
|
62
63
|
}) => Promise<boolean>;
|
|
63
64
|
/**
|
|
64
65
|
* Checks the signature of a URL, such as `request.url` in a handler.
|
|
@@ -66,7 +67,7 @@ export declare namespace SignedUrl {
|
|
|
66
67
|
* @throws {InvalidSignatureError} When the URL was tampered with or has expired (403).
|
|
67
68
|
*/
|
|
68
69
|
const assert: (url: string | URL, options: {
|
|
69
|
-
secret:
|
|
70
|
+
secret: Secret;
|
|
70
71
|
}) => Promise<void>;
|
|
71
72
|
}
|
|
72
73
|
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elysiumjs/themis",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "Authentication for the Elysium.js framework: passwords, tokens, sessions, guards, CSRF, TOTP, OAuth and passkeys.",
|
|
6
6
|
"private": false,
|
|
@@ -61,11 +61,11 @@
|
|
|
61
61
|
"oauth4webapi": "^3.8.8"
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
|
-
"@elysiumjs/core": "0.
|
|
65
|
-
"@elysiumjs/runtime-bun": "0.1.
|
|
64
|
+
"@elysiumjs/core": "0.12.0",
|
|
65
|
+
"@elysiumjs/runtime-bun": "0.1.1",
|
|
66
66
|
"elysia": "^2.0.0-rc.1"
|
|
67
67
|
},
|
|
68
68
|
"peerDependencies": {
|
|
69
|
-
"@elysiumjs/core": "^0.
|
|
69
|
+
"@elysiumjs/core": "^0.12.0"
|
|
70
70
|
}
|
|
71
71
|
}
|