├── .gitignore ├── bun.lockb ├── docker-compose.yml ├── .bumpversion.cfg ├── tsconfig.json ├── .github └── workflows │ ├── checks.yml │ └── publish.yml ├── LICENSE ├── package.json ├── README.md ├── src └── index.ts └── tests └── index.test.ts /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leigholiver/elysia-ironsession/main/bun.lockb -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | elysia-ironsession: 3 | image: oven/bun:latest 4 | volumes: 5 | - ./:/home/bun/app 6 | command: [bash] 7 | -------------------------------------------------------------------------------- /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.1.2 3 | 4 | [bumpversion:file:package.json] 5 | search = "version": "{current_version}", 6 | replace = "version": "{new_version}", 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": [ 4 | "ESNext" 5 | ], 6 | "module": "ESNext", 7 | "target": "ESNext", 8 | "moduleResolution": "Bundler", 9 | "esModuleInterop": true, 10 | "strict": true, 11 | "skipLibCheck": true, 12 | "allowSyntheticDefaultImports": true, 13 | "rootDir": "./src", 14 | "outDir": "./dist", 15 | "declaration": true, 16 | "declarationMap": true, 17 | "sourceMap": true, 18 | "noEmit": false 19 | }, 20 | "include": [ 21 | "src" 22 | ] 23 | } -------------------------------------------------------------------------------- /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: Pull Request Checks 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | test-and-build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | 14 | - name: Setup Bun 15 | uses: oven-sh/setup-bun@v2 16 | 17 | - name: Install dependencies 18 | run: bun install 19 | 20 | - name: Run tests 21 | run: bun run test 22 | 23 | - name: Run build 24 | run: bun run build 25 | 26 | - name: Check if version already exists 27 | id: version_check 28 | run: | 29 | latest_release=$(curl -s https://api.github.com/repos/${{ github.repository }}/releases/latest | jq -r .tag_name || echo "v0.0.0") 30 | version=$(jq -r .version package.json) 31 | echo "Latest release: $latest_release" 32 | echo "Current version: v$version" 33 | if [ "$latest_release" = "v$version" ]; then 34 | echo "Version v$version has already been released" 35 | exit 1 36 | fi 37 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Main Branch Publish 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build-and-publish: 10 | permissions: 11 | contents: write 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Setup Bun 17 | uses: oven-sh/setup-bun@v2 18 | 19 | - name: Install dependencies 20 | run: bun install 21 | 22 | - name: Build 23 | run: bun run build 24 | 25 | - name: Publish to NPM 26 | run: bun publish 27 | env: 28 | NPM_CONFIG_TOKEN: ${{ secrets.NPM_CONFIG_TOKEN }} 29 | 30 | - name: Get version 31 | id: package_version 32 | run: | 33 | echo "version=$(jq -r .version package.json)" >> $GITHUB_OUTPUT 34 | 35 | - name: Create GitHub Release 36 | run: | 37 | gh release create "v${{ steps.package_version.outputs.version }}" \ 38 | --title "Release v${{ steps.package_version.outputs.version }}" \ 39 | --generate-notes 40 | env: 41 | GH_TOKEN: ${{ github.token }} 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Leigh Oliver 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "elysia-ironsession", 3 | "description": "A secure session management plugin for Elysia.js using iron-session.", 4 | "keywords": [ 5 | "elysia", 6 | "elysia-plugin", 7 | "session", 8 | "iron-session" 9 | ], 10 | "version": "0.1.2", 11 | "author": "Leigh Oliver", 12 | "bugs": "https://github.com/leigholiver/elysia-ironsession/issues", 13 | "homepage": "https://github.com/leigholiver/elysia-ironsession", 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/leigholiver/elysia-ironsession" 17 | }, 18 | "license": "MIT", 19 | "main": "dist/index.js", 20 | "types": "dist/index.d.ts", 21 | "files": [ 22 | "dist", 23 | "README.md" 24 | ], 25 | "scripts": { 26 | "dev": "bun --watch src/index.ts", 27 | "build": "tsc", 28 | "test": "bun test ./tests/** --coverage" 29 | }, 30 | "dependencies": { 31 | "iron-session": "^8.0.4" 32 | }, 33 | "peerDependencies": { 34 | "elysia": ">=1.0.0" 35 | }, 36 | "devDependencies": { 37 | "@types/bun": "^1.2.5", 38 | "typescript": "^5.8.2" 39 | }, 40 | "exports": { 41 | ".": { 42 | "import": "./dist/index.js", 43 | "types": "./dist/index.d.ts" 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # elysia-ironsession 2 | 3 | A secure session management plugin for Elysia.js using [iron-session](https://github.com/vvo/iron-session). This plugin provides encrypted, stateless sessions with type-safety. 4 | 5 | ## Features 6 | 7 | - 🔒 Secure, encrypted session storage using iron-session 8 | - 🔑 HTTP-only cookie-based sessions 9 | - ⚡ Fully type-safe with TypeScript 10 | - 🎯 Simple, intuitive API 11 | - ⏰ Configurable TTL (Time To Live) 12 | 13 | ## Installation 14 | 15 | ```bash 16 | bun add elysia-ironsession 17 | ``` 18 | 19 | ## Usage 20 | Usage is the same as [Elysia's reactive Cookie](https://elysiajs.com/patterns/cookie#reactivity) - you extract the `session` property and access its items directly. 21 | 22 | There's no get/set, you can extract the property name and retrieve or update its value directly. 23 | 24 | Basic example: 25 | 26 | ```typescript 27 | import { Elysia } from 'elysia' 28 | import { IronSession } from 'elysia-ironsession' 29 | 30 | // Define your session structure 31 | interface UserSession { 32 | userId?: number 33 | isLoggedIn?: boolean 34 | } 35 | 36 | const app = new Elysia() 37 | .use( 38 | IronSession({ 39 | password: process.env.SESSION_SECRET!, // At least 32 characters 40 | cookieName: 'my_session', // Optional, defaults to 'session' 41 | secure: process.env.NODE_ENV === 'production' 42 | }) 43 | ) 44 | .get('/profile', async ({ session }) => { 45 | if (!session?.isLoggedIn) { 46 | throw new Error('Unauthorized') 47 | } 48 | return { userId: session.userId } 49 | }) 50 | .get('/login', async ({ session }) => { 51 | session.userId = 123 52 | session.isLoggedIn = true 53 | return { success: true } 54 | }) 55 | .get('/logout', async ({ session }) => { 56 | delete session.userId 57 | delete session.isLoggedIn 58 | return { success: true } 59 | }) 60 | .listen(3000) 61 | ``` 62 | 63 | ## Configuration 64 | 65 | The plugin accepts the following options: 66 | 67 | ```typescript 68 | interface SessionOptions { 69 | password: string; // Required: Secret key for encryption (min 32 chars) 70 | ttl?: number; // Optional: Session duration in seconds (default: 14 days) 71 | cookieName?: string; // Optional: Name of the session cookie (default: 'session') 72 | secure?: boolean; // Optional: Set the secure attribute of the cookie (default true) 73 | } 74 | ``` 75 | 76 | ## Contributing 77 | 78 | Contributions are welcome! Please feel free to submit a Pull Request. 79 | 80 | ## License 81 | 82 | MIT License - see LICENSE file for details. 83 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Elysia } from "elysia"; 2 | import { sealData, unsealData } from "iron-session"; 3 | 4 | interface SessionOptions { 5 | password: string; 6 | ttl?: number; 7 | cookieName?: string; 8 | secure?: boolean; 9 | } 10 | 11 | const defaultOptions = { 12 | ttl: 14 * 24 * 60 * 60, // 14 days 13 | cookieName: "session", 14 | secure: true, 15 | } as const; 16 | 17 | type IronSession = { 18 | [P in keyof T]: T[P] extends object ? IronSession : T[P]; 19 | }; 20 | 21 | export const IronSession = (options: SessionOptions) => { 22 | const sessionOptions = { ...defaultOptions, ...options }; 23 | 24 | return ( 25 | new Elysia() 26 | .derive({ as: "global" }, async ({ cookie }) => { 27 | const session = cookie[sessionOptions.cookieName]; 28 | let updatePromise: Promise | null = null; 29 | 30 | const updateSession = async (newData: T) => { 31 | const sealed = await sealData(newData, { 32 | password: sessionOptions.password, 33 | ttl: sessionOptions.ttl, 34 | }); 35 | session.value = sealed; 36 | session.maxAge = sessionOptions.ttl; 37 | session.httpOnly = true; 38 | session.secure = sessionOptions.secure; 39 | }; 40 | 41 | // Create a deep proxy for nested objects 42 | const createDeepProxy = ( 43 | target: K, 44 | rootData: T, 45 | ): IronSession => { 46 | return new Proxy(target, { 47 | get(target, property: string | symbol) { 48 | const value = target[property as keyof K]; 49 | if (value && typeof value === "object") { 50 | return createDeepProxy(value as object, rootData); 51 | } 52 | return value; 53 | }, 54 | 55 | set(target, property: string | symbol, value: any): boolean { 56 | if (value && typeof value === "object") { 57 | target[property as keyof K] = createDeepProxy( 58 | value, 59 | rootData, 60 | ) as K[keyof K]; 61 | } else { 62 | target[property as keyof K] = value; 63 | } 64 | updatePromise = updateSession(rootData); 65 | return true; 66 | }, 67 | 68 | deleteProperty(target, property: string | symbol): boolean { 69 | delete target[property as keyof K]; 70 | updatePromise = updateSession(rootData); 71 | return true; 72 | }, 73 | }) as IronSession; 74 | }; 75 | 76 | // Initialize session data 77 | let sessionData: T; 78 | try { 79 | sessionData = session?.value 80 | ? await unsealData(session.value, { 81 | password: sessionOptions.password, 82 | ttl: sessionOptions.ttl, 83 | }) 84 | : ({} as T); 85 | } catch (e) { 86 | sessionData = {} as T; 87 | } 88 | 89 | // Wrap the session data in a deep proxy 90 | const wrappedSession = createDeepProxy(sessionData, sessionData); 91 | 92 | return { 93 | session: wrappedSession, 94 | commitSession: async () => { 95 | if (updatePromise) { 96 | await updatePromise; 97 | updatePromise = null; 98 | } 99 | }, 100 | }; 101 | }) 102 | // make sure the cookie is updated and set-cookie header is added before the request 103 | .onAfterHandle({ as: "global" }, async ({ response, commitSession }) => { 104 | await commitSession(); 105 | return response; 106 | }) 107 | ); 108 | }; 109 | 110 | export default IronSession; 111 | -------------------------------------------------------------------------------- /tests/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, beforeEach } from 'bun:test' 2 | import { Elysia } from 'elysia' 3 | import IronSession from '../src' 4 | 5 | interface TestSession { 6 | userId?: number 7 | isLoggedIn?: boolean 8 | } 9 | 10 | describe('Elysia Session Plugin', () => { 11 | const SESSION_SECRET = 'your-super-secret-password-at-least-32-chars' 12 | 13 | const newElysia = () => { 14 | return new Elysia() 15 | .use(IronSession({ 16 | password: SESSION_SECRET, 17 | cookieName: 'test_session' 18 | })) 19 | } 20 | 21 | let app: ReturnType 22 | 23 | beforeEach(() => { 24 | app = newElysia() 25 | }) 26 | 27 | it('should handle new session with no data', async () => { 28 | app.get('/test-empty', async ({ session }) => { 29 | return { sessionData: session } 30 | }) 31 | 32 | const response = await app 33 | .handle(new Request('http://localhost/test-empty')) 34 | .then(res => res.json()) 35 | 36 | expect(response).toEqual({ sessionData: {} }) 37 | }) 38 | 39 | it('should set and retrieve session data', async () => { 40 | app.post('/login', async ({ session }) => { 41 | session.userId = 123 42 | session.isLoggedIn = true 43 | return { success: true } 44 | }) 45 | .get('/profile', async ({ session }) => { 46 | return { sessionData: session } 47 | }) 48 | 49 | // First, set the session data 50 | const loginResponse = await app 51 | .handle(new Request('http://localhost/login', { method: 'POST' })) 52 | 53 | // Get the session cookie from the response 54 | const sessionCookie = loginResponse.headers.get('set-cookie') 55 | expect(sessionCookie).toBeTruthy() 56 | 57 | // Now try to retrieve the session data with the cookie 58 | const profileResponse = await app 59 | .handle(new Request('http://localhost/profile', { 60 | headers: { 61 | cookie: sessionCookie! 62 | } 63 | })) 64 | 65 | const profileData = await profileResponse.json() 66 | expect(profileData).toEqual({ 67 | sessionData: { 68 | userId: 123, 69 | isLoggedIn: true 70 | } 71 | }) 72 | }) 73 | 74 | it('should handle invalid session data', async () => { 75 | app.get('/test-invalid', async ({ session }) => { 76 | return { sessionData: session } 77 | }) 78 | 79 | // Try with an invalid session cookie 80 | const response = await app 81 | .handle(new Request('http://localhost/test-invalid', { 82 | headers: { 83 | cookie: 'test_session=invalid-data' 84 | } 85 | })) 86 | .then(res => res.json()) 87 | 88 | expect(response).toEqual({ sessionData: {} }) 89 | }) 90 | 91 | it('should update existing session data', async () => { 92 | app.post('/set-initial', async ({ session }) => { 93 | session.userId = 123 94 | session.isLoggedIn = false 95 | return { success: true } 96 | }) 97 | .post('/update', async ({ session }) => { 98 | session.isLoggedIn = true 99 | return session 100 | }) 101 | 102 | // Set initial session data 103 | const initialResponse = await app 104 | .handle(new Request('http://localhost/set-initial', { method: 'POST' })) 105 | const sessionCookie = initialResponse.headers.get('set-cookie') 106 | 107 | // Update the session 108 | const updateResponse = await app 109 | .handle(new Request('http://localhost/update', { 110 | method: 'POST', 111 | headers: { 112 | cookie: sessionCookie! 113 | } 114 | })) 115 | 116 | const updatedData = await updateResponse.json() 117 | expect(updatedData).toEqual({ 118 | userId: 123, 119 | isLoggedIn: true 120 | }) 121 | }) 122 | 123 | it('should handle new session creation with direct assignment', async () => { 124 | app.post('/set-new', async ({ session }) => { 125 | session.userId = 999 126 | session.isLoggedIn = true 127 | return { sessionData: session } 128 | }) 129 | 130 | const response = await app 131 | .handle(new Request('http://localhost/set-new', { 132 | method: 'POST' 133 | })) 134 | 135 | const cookieHeader = response.headers.get('set-cookie') 136 | expect(cookieHeader).toBeTruthy() 137 | 138 | const responseData = await response.json() 139 | expect(responseData).toEqual({ 140 | sessionData: { 141 | userId: 999, 142 | isLoggedIn: true 143 | } 144 | }) 145 | }) 146 | 147 | it('should handle deletion of session data', async () => { 148 | app.post('/set-data', async ({ session }) => { 149 | session.userId = 123 150 | session.isLoggedIn = true 151 | return { success: true } 152 | }) 153 | .post('/destroy', async ({ session }) => { 154 | delete session.userId 155 | delete session.isLoggedIn 156 | return { session } 157 | }) 158 | 159 | // First set some data 160 | const setResponse = await app 161 | .handle(new Request('http://localhost/set-data', { method: 'POST' })) 162 | const sessionCookie = setResponse.headers.get('set-cookie') 163 | 164 | // Then destroy it 165 | const destroyResponse = await app 166 | .handle(new Request('http://localhost/destroy', { 167 | method: 'POST', 168 | headers: { 169 | cookie: sessionCookie! 170 | } 171 | })) 172 | 173 | const responseData = await destroyResponse.json() 174 | expect(responseData.session).toEqual({}) 175 | }) 176 | 177 | it('should handle empty options', async () => { 178 | const minimalApp = new Elysia() 179 | .use(IronSession({ 180 | password: SESSION_SECRET 181 | })) 182 | 183 | minimalApp.get('/test', async ({ session }) => { 184 | return { sessionData: session } 185 | }) 186 | 187 | const response = await minimalApp 188 | .handle(new Request('http://localhost/test')) 189 | .then(res => res.json()) 190 | 191 | expect(response).toEqual({ sessionData: {} }) 192 | }) 193 | 194 | it('should set all cookie properties correctly', async () => { 195 | const customApp = new Elysia() 196 | .use(IronSession({ 197 | password: SESSION_SECRET, 198 | cookieName: 'custom_session', 199 | ttl: 3600, 200 | secure: true 201 | })) 202 | 203 | customApp.post('/set-all', async ({ session }) => { 204 | session.userId = 123 205 | return { sessionData: session } 206 | }) 207 | 208 | const response = await customApp 209 | .handle(new Request('http://localhost/set-all', { method: 'POST' })) 210 | 211 | const cookieHeader = response.headers.get('set-cookie') 212 | expect(cookieHeader).toBeTruthy() 213 | expect(cookieHeader).toContain('Max-Age=3600') 214 | expect(cookieHeader).toContain('HttpOnly') 215 | expect(cookieHeader).toContain('Secure') 216 | expect(cookieHeader).toContain('custom_session=') 217 | 218 | const data = await response.json() 219 | expect(data).toEqual({ 220 | sessionData: { 221 | userId: 123 222 | } 223 | }) 224 | }) 225 | 226 | it('should handle nested session data', async () => { 227 | interface NestedSession { 228 | user?: { 229 | profile?: { 230 | name?: string 231 | settings?: { 232 | theme?: string 233 | } 234 | } 235 | } 236 | } 237 | 238 | const nestedApp = new Elysia() 239 | .use(IronSession({ 240 | password: SESSION_SECRET 241 | })) 242 | 243 | nestedApp.post('/set-nested', async ({ session }) => { 244 | session.user = { 245 | profile: { 246 | name: 'John', 247 | settings: { 248 | theme: 'dark' 249 | } 250 | } 251 | } 252 | return { success: true } 253 | }) 254 | .get('/get-nested', async ({ session }) => { 255 | return { data: session } 256 | }) 257 | 258 | // Set nested data 259 | const setResponse = await nestedApp 260 | .handle(new Request('http://localhost/set-nested', { method: 'POST' })) 261 | const sessionCookie = setResponse.headers.get('set-cookie') 262 | 263 | // Get nested data 264 | const getResponse = await nestedApp 265 | .handle(new Request('http://localhost/get-nested', { 266 | headers: { 267 | cookie: sessionCookie! 268 | } 269 | })) 270 | 271 | const responseData = await getResponse.json() 272 | expect(responseData.data).toEqual({ 273 | user: { 274 | profile: { 275 | name: 'John', 276 | settings: { 277 | theme: 'dark' 278 | } 279 | } 280 | } 281 | }) 282 | }) 283 | }) 284 | --------------------------------------------------------------------------------