diff --git a/.env.example b/.env.example index c8e3577..0ff8103 100644 --- a/.env.example +++ b/.env.example @@ -20,7 +20,8 @@ PORT=8787 ENVIRONMENT=production API_VERSION=v1 PROTOCOL_VERSION=1.0 -JWT_SECRET=GENERATE_ME +BETTER_AUTH_SECRET=GENERATE_ME +BETTER_AUTH_URL=http://localhost:8787 ELYDORA_SIGNING_KEY=GENERATE_ME ALLOWED_ORIGINS=http://localhost:3000 diff --git a/README.md b/README.md index 0100536..7a2d171 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ cd Elydora-Open-Source The install script automatically: 1. Checks prerequisites (Docker, Docker Compose, OpenSSL, curl) -2. Generates a `.env` with fresh cryptographic secrets (Ed25519 signing key, JWT secret, database and object-store passwords) +2. Generates a `.env` with fresh cryptographic secrets (Ed25519 signing key, Better Auth secret, database and object-store passwords) 3. Starts all services via `docker compose up -d` 4. Waits for the API to become healthy @@ -154,14 +154,15 @@ Elydora-Open-Source/ ### Registration and Authentication Flow ``` -1. Register organization and first user - POST /v1/auth/register → { user, organization, token } +1. Register organization and first user (Better Auth) + POST /api/auth/sign-up/email → { user, session } -2. Log in to retrieve a JWT - POST /v1/auth/login → { user, token } +2. Log in to retrieve a session token (Better Auth) + POST /api/auth/sign-in/email → { user, session } -3. Issue a long-lived API token (optional) +3. Issue a long-lived API token for SDK use POST /v1/auth/token → { token, expires_at } + Header: Authorization: Bearer 4. Register an agent (as integration_engineer) POST /v1/agents/register → { agent, keys } @@ -220,9 +221,11 @@ Errors are returned as: ### Authentication -#### `POST /v1/auth/register` +Authentication is handled by Better Auth. Use the `/api/auth/` endpoints for session management, then issue API tokens for SDK use via `/v1/auth/token`. -Register a new user and create an organization. Returns a JWT for immediate use. +#### `POST /api/auth/sign-up/email` + +Register a new user. Organization setup is completed during onboarding after registration. **Request body:** @@ -230,36 +233,27 @@ Register a new user and create an organization. Returns a JWT for immediate use. |-------|------|----------|-------------| | `email` | string | yes | User email address | | `password` | string | yes | User password | -| `display_name` | string | no | Human-readable name for the user | -| `org_name` | string | no | Name for the new organization | +| `name` | string | no | Human-readable display name | -**Response:** - -```json -{ - "user": { "user_id": "...", "email": "...", "role": "org_owner", ... }, - "organization": { "org_id": "...", "name": "..." }, - "token": "" -} -``` +**Response:** `{ "user": { ... }, "session": { ... } }` --- -#### `POST /v1/auth/login` +#### `POST /api/auth/sign-in/email` -Authenticate an existing user. +Authenticate an existing user and receive a session token. **Request body:** `{ "email": "...", "password": "..." }` -**Response:** `{ "user": { ... }, "token": "" }` +**Response:** `{ "user": { ... }, "session": { "token": "", ... } }` --- -#### `GET /v1/auth/me` +#### `GET /api/auth/session` -Return the authenticated user's profile. Requires a valid JWT. +Return the current session and user profile. Requires a valid session token or cookie. -**Response:** `{ "user": { "user_id": "...", "email": "...", "role": "...", ... } }` +**Response:** `{ "user": { ... }, "session": { ... } }` --- @@ -865,7 +859,8 @@ Elydora is configured via environment variables. Copy `.env.example` to `.env` a | `STORAGE_BUCKET` | Bucket name for payloads, receipts, and exports | | `STORAGE_ACCESS_KEY_ID` | Object storage access key | | `STORAGE_SECRET_ACCESS_KEY` | Object storage secret key | -| `JWT_SECRET` | HMAC-SHA256 secret for signing user JWTs | +| `BETTER_AUTH_SECRET` | Better Auth secret for session management | +| `BETTER_AUTH_URL` | Better Auth base URL (e.g. `http://localhost:8787`) | | `SERVER_PRIVATE_KEY` | Base64url-encoded Ed25519 seed for signing EARs and JWKS | ### Optional @@ -892,7 +887,7 @@ Elydora is configured via environment variables. Copy `.env.example` to `.env` a **PBKDF2-SHA256 password hashing** — User passwords are hashed with PBKDF2-SHA256 before storage. -**HMAC-SHA256 JWT signing** — User sessions are signed with a server-side HMAC secret. Tokens cannot be forged without the secret. +**Better Auth session-based authentication** — User sessions are managed by Better Auth with secure server-side session storage. Session tokens cannot be forged without the secret. **RFC 3161 TSA anchoring** — Epoch Merkle roots are submitted to a public timestamp authority, providing independent third-party attestation of when records existed. This prevents retroactive alteration of historical data even by the platform operator. diff --git a/helm/elydora/templates/secret.yaml b/helm/elydora/templates/secret.yaml index 0c8334c..3998f23 100644 --- a/helm/elydora/templates/secret.yaml +++ b/helm/elydora/templates/secret.yaml @@ -8,7 +8,7 @@ metadata: type: Opaque stringData: DATABASE_URL: {{ include "elydora.databaseUrl" . | quote }} - JWT_SECRET: {{ .Values.api.jwtSecret | quote }} + BETTER_AUTH_SECRET: {{ .Values.api.betterAuthSecret | quote }} ELYDORA_SIGNING_KEY: {{ .Values.api.signingKey | quote }} POSTGRES_PASSWORD: {{ .Values.postgres.password | quote }} MINIO_SECRET_KEY: {{ .Values.minio.rootPassword | quote }} diff --git a/helm/elydora/values.yaml b/helm/elydora/values.yaml index cff41a2..7fc758f 100644 --- a/helm/elydora/values.yaml +++ b/helm/elydora/values.yaml @@ -77,7 +77,7 @@ minio: api: port: 8787 environment: production - jwtSecret: change-me-to-a-random-string-at-least-32-chars + betterAuthSecret: change-me-to-a-random-string-at-least-32-chars signingKey: change-me-base64url-ed25519-private-key allowedOrigins: "https://console.example.com" tsaUrl: "http://timestamp.sectigo.com" diff --git a/packages/console/package-lock.json b/packages/console/package-lock.json index 1c515dd..c983446 100644 --- a/packages/console/package-lock.json +++ b/packages/console/package-lock.json @@ -8,6 +8,7 @@ "name": "elydora-console", "version": "0.1.0", "dependencies": { + "better-auth": "^1.5.5", "i18next": "^25.8.13", "i18next-browser-languagedetector": "^8.2.1", "next": "^16.1.6", @@ -48,6 +49,134 @@ "node": ">=6.9.0" } }, + "node_modules/@better-auth/core": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.5.6.tgz", + "integrity": "sha512-Ez9DZdIMFyxHremmoLz1emFPGNQomDC1jqqBPnZ6Ci+6TiGN3R9w/Y03cJn6I8r1ycKgOzeVMZtJ/erOZ27Gsw==", + "license": "MIT", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.39.0", + "@standard-schema/spec": "^1.1.0", + "zod": "^4.3.6" + }, + "peerDependencies": { + "@better-auth/utils": "0.3.1", + "@better-fetch/fetch": "1.1.21", + "@cloudflare/workers-types": ">=4", + "@opentelemetry/api": "^1.9.0", + "better-call": "1.3.2", + "jose": "^6.1.0", + "kysely": "^0.28.5", + "nanostores": "^1.0.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/@better-auth/drizzle-adapter": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.5.6.tgz", + "integrity": "sha512-VfFFmaoFw3ug12SiSuIwzrMoHyIVmkMGWm9gZ4sXdYYVX4HboCL4m3fjzOhppcmK5OGatRuU+N1UX6wxCITcXw==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "1.5.6", + "@better-auth/utils": "^0.3.0", + "drizzle-orm": ">=0.41.0" + }, + "peerDependenciesMeta": { + "drizzle-orm": { + "optional": true + } + } + }, + "node_modules/@better-auth/kysely-adapter": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.5.6.tgz", + "integrity": "sha512-Fnf+h8WVKtw6lEOmVmiVVzDf3shJtM60AYf9XTnbdCeUd6MxN/KnaJZpkgtYnRs7a+nwtkVB+fg4lGETebGFXQ==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "1.5.6", + "@better-auth/utils": "^0.3.0", + "kysely": "^0.27.0 || ^0.28.0" + }, + "peerDependenciesMeta": { + "kysely": { + "optional": true + } + } + }, + "node_modules/@better-auth/memory-adapter": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.5.6.tgz", + "integrity": "sha512-rS7ZsrIl5uvloUgNN0u9LOZJMMXnsZXVdUZ3MrTBKWM2KpoJjzPr9yN3Szyma5+0V7SltnzSGHPkYj2bEzzmlA==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "1.5.6", + "@better-auth/utils": "^0.3.0" + } + }, + "node_modules/@better-auth/mongo-adapter": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.5.6.tgz", + "integrity": "sha512-6+M3MS2mor8fTUV3EI1FBLP0cs6QfbN+Ovx9+XxR/GdfKIBoNFzmPEPRbdGt+ft6PvrITsUm+T70+kkHgVSP6w==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "1.5.6", + "@better-auth/utils": "^0.3.0", + "mongodb": "^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "mongodb": { + "optional": true + } + } + }, + "node_modules/@better-auth/prisma-adapter": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.5.6.tgz", + "integrity": "sha512-UxY9vQJs1Tt+O+T2YQnseDMlWmUSQvFZSBb5YiFRg7zcm+TEzujh4iX2/csA0YiZptLheovIuVWTP9nriewEBA==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "1.5.6", + "@better-auth/utils": "^0.3.0", + "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", + "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "@prisma/client": { + "optional": true + }, + "prisma": { + "optional": true + } + } + }, + "node_modules/@better-auth/telemetry": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.5.6.tgz", + "integrity": "sha512-yXC7NSxnIFlxDkGdpD7KA+J9nqIQAPCJKe77GoaC5bWoe/DALo1MYorZfTgOafS7wrslNtsPT4feV/LJi1ubqQ==", + "license": "MIT", + "dependencies": { + "@better-auth/utils": "0.3.1", + "@better-fetch/fetch": "1.1.21" + }, + "peerDependencies": { + "@better-auth/core": "1.5.6" + } + }, + "node_modules/@better-auth/utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.3.1.tgz", + "integrity": "sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg==", + "license": "MIT" + }, + "node_modules/@better-fetch/fetch": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.1.21.tgz", + "integrity": "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==" + }, "node_modules/@emnapi/runtime": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", @@ -708,6 +837,55 @@ "node": ">= 10" } }, + "node_modules/@noble/ciphers": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", + "integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz", + "integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1030,6 +1208,131 @@ "node": ">=6.0.0" } }, + "node_modules/better-auth": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.5.6.tgz", + "integrity": "sha512-QSpJTqaT1XVfWRQe/fm3PgeuwOIlz1nWX/Dx7nsHStJ382bLzmDbQk2u7IT0IJ6wS5SRxfqEE1Ev9TXontgyAQ==", + "license": "MIT", + "dependencies": { + "@better-auth/core": "1.5.6", + "@better-auth/drizzle-adapter": "1.5.6", + "@better-auth/kysely-adapter": "1.5.6", + "@better-auth/memory-adapter": "1.5.6", + "@better-auth/mongo-adapter": "1.5.6", + "@better-auth/prisma-adapter": "1.5.6", + "@better-auth/telemetry": "1.5.6", + "@better-auth/utils": "0.3.1", + "@better-fetch/fetch": "1.1.21", + "@noble/ciphers": "^2.1.1", + "@noble/hashes": "^2.0.1", + "better-call": "1.3.2", + "defu": "^6.1.4", + "jose": "^6.1.3", + "kysely": "^0.28.12", + "nanostores": "^1.1.1", + "zod": "^4.3.6" + }, + "peerDependencies": { + "@lynx-js/react": "*", + "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", + "@sveltejs/kit": "^2.0.0", + "@tanstack/react-start": "^1.0.0", + "@tanstack/solid-start": "^1.0.0", + "better-sqlite3": "^12.0.0", + "drizzle-kit": ">=0.31.4", + "drizzle-orm": ">=0.41.0", + "mongodb": "^6.0.0 || ^7.0.0", + "mysql2": "^3.0.0", + "next": "^14.0.0 || ^15.0.0 || ^16.0.0", + "pg": "^8.0.0", + "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0", + "solid-js": "^1.0.0", + "svelte": "^4.0.0 || ^5.0.0", + "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", + "vue": "^3.0.0" + }, + "peerDependenciesMeta": { + "@lynx-js/react": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@sveltejs/kit": { + "optional": true + }, + "@tanstack/react-start": { + "optional": true + }, + "@tanstack/solid-start": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "drizzle-kit": { + "optional": true + }, + "drizzle-orm": { + "optional": true + }, + "mongodb": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "next": { + "optional": true + }, + "pg": { + "optional": true + }, + "prisma": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "solid-js": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vitest": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/better-call": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.3.2.tgz", + "integrity": "sha512-4cZIfrerDsNTn3cm+MhLbUePN0gdwkhSXEuG7r/zuQ8c/H7iU0/jSK5TD3FW7U0MgKHce/8jGpPYNO4Ve+4NBw==", + "license": "MIT", + "dependencies": { + "@better-auth/utils": "^0.3.1", + "@better-fetch/fetch": "^1.1.21", + "rou3": "^0.7.12", + "set-cookie-parser": "^3.0.1" + }, + "peerDependencies": { + "zod": "^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001774", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", @@ -1063,6 +1366,12 @@ "dev": true, "license": "MIT" }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "license": "MIT" + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -1162,6 +1471,24 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/kysely": { + "version": "0.28.14", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.14.tgz", + "integrity": "sha512-SU3lgh0rPvq7upc6vvdVrCsSMUG1h3ChvHVOY7wJ2fw4C9QEB7X3d5eyYEyULUX7UQtxZJtZXGuT6U2US72UYA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/lightningcss": { "version": "1.31.1", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", @@ -1451,6 +1778,21 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nanostores": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.2.0.tgz", + "integrity": "sha512-F0wCzbsH80G7XXo0Jd9/AVQC7ouWY6idUCTnMwW5t/Rv9W8qmO6endavDwg7TNp5GbugwSukFMVZqzPSrSMndg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, "node_modules/next": { "version": "16.1.6", "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz", @@ -1615,6 +1957,12 @@ } } }, + "node_modules/rou3": { + "version": "0.7.12", + "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.7.12.tgz", + "integrity": "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -1634,6 +1982,12 @@ "node": ">=10" } }, + "node_modules/set-cookie-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "license": "MIT" + }, "node_modules/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -1789,6 +2143,15 @@ "engines": { "node": ">=0.10.0" } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/packages/console/package.json b/packages/console/package.json index 90114f7..a2fbd4c 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -9,6 +9,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "better-auth": "^1.5.5", "i18next": "^25.8.13", "i18next-browser-languagedetector": "^8.2.1", "next": "^16.1.6", diff --git a/packages/console/src/app/login/page.tsx b/packages/console/src/app/login/page.tsx index dcd36f2..59f5103 100644 --- a/packages/console/src/app/login/page.tsx +++ b/packages/console/src/app/login/page.tsx @@ -3,14 +3,12 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; -import { api } from '@/lib/api'; -import { useAuth } from '@/lib/auth'; +import { signIn } from '@/lib/auth-client'; import { useTranslation } from 'react-i18next'; export default function LoginPage() { const { t } = useTranslation(); const router = useRouter(); - const { login } = useAuth(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(''); @@ -21,8 +19,10 @@ export default function LoginPage() { setError(''); setIsSubmitting(true); try { - const result = await api.auth.login(email, password); - login(result.token, { display_name: result.user.display_name, email: result.user.email }); + const result = await signIn.email({ email, password }); + if (result.error) { + throw new Error(result.error.message ?? t('login.loginFailed')); + } router.push('/'); } catch (err) { setError(err instanceof Error ? err.message : t('login.loginFailed')); diff --git a/packages/console/src/app/register/page.tsx b/packages/console/src/app/register/page.tsx index 75a1565..d9a7be8 100644 --- a/packages/console/src/app/register/page.tsx +++ b/packages/console/src/app/register/page.tsx @@ -3,14 +3,12 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; -import { api } from '@/lib/api'; -import { useAuth } from '@/lib/auth'; +import { signUp } from '@/lib/auth-client'; import { useTranslation } from 'react-i18next'; export default function RegisterPage() { const { t } = useTranslation(); const router = useRouter(); - const { login } = useAuth(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [displayName, setDisplayName] = useState(''); @@ -23,8 +21,10 @@ export default function RegisterPage() { setError(''); setIsSubmitting(true); try { - const result = await api.auth.register(email, password, displayName, orgName); - login(result.token, { display_name: result.user.display_name, email: result.user.email }); + const result = await signUp.email({ email, password, name: displayName }); + if (result.error) { + throw new Error(result.error.message ?? t('register.registrationFailed')); + } router.push('/'); } catch (err) { setError(err instanceof Error ? err.message : t('register.registrationFailed')); diff --git a/packages/console/src/components/AgentRegistrationForm.tsx b/packages/console/src/components/AgentRegistrationForm.tsx index 439395c..c2ec864 100644 --- a/packages/console/src/components/AgentRegistrationForm.tsx +++ b/packages/console/src/components/AgentRegistrationForm.tsx @@ -76,7 +76,7 @@ const INTEGRATIONS: Integration[] = [ export default function AgentRegistrationForm({ onSuccess, onCancel }: AgentRegistrationFormProps) { const { t } = useTranslation(); - const { user, token: authToken } = useAuth(); + const { user } = useAuth(); const [displayName, setDisplayName] = useState(''); const [responsibleEntity, setResponsibleEntity] = useState(''); const [submitting, setSubmitting] = useState(false); @@ -186,9 +186,9 @@ export default function AgentRegistrationForm({ onSuccess, onCancel }: AgentRegi const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL ?? 'http://localhost:8787'; // CLI install commands for supported integrations const buildCliCommands = (integration: Integration) => ({ - node: `npx @elydora/sdk install --agent ${integration.agentFlag} --org_id "${creds.orgId}" --agent_id "${creds.agentId}" --private_key "${creds.privateKey}" --kid "${creds.kid}" --token "${apiToken ?? authToken ?? ''}" --base_url "${apiBaseUrl}"`, - python: `pip install elydora && elydora install --agent ${integration.agentFlag} --org_id "${creds.orgId}" --agent_id "${creds.agentId}" --private_key "${creds.privateKey}" --kid "${creds.kid}" --token "${apiToken ?? authToken ?? ''}" --base_url "${apiBaseUrl}"`, - go: `go install github.com/Elydora-Infrastructure/Elydora-Go-SDK/cmd/elydora@latest && elydora install --agent ${integration.agentFlag} --org_id "${creds.orgId}" --agent_id "${creds.agentId}" --private_key "${creds.privateKey}" --kid "${creds.kid}" --token "${apiToken ?? authToken ?? ''}" --base-url "${apiBaseUrl}"`, + node: `npx @elydora/sdk install --agent ${integration.agentFlag} --org_id "${creds.orgId}" --agent_id "${creds.agentId}" --private_key "${creds.privateKey}" --kid "${creds.kid}" --token "${apiToken ?? ''}" --base_url "${apiBaseUrl}"`, + python: `pip install elydora && elydora install --agent ${integration.agentFlag} --org_id "${creds.orgId}" --agent_id "${creds.agentId}" --private_key "${creds.privateKey}" --kid "${creds.kid}" --token "${apiToken ?? ''}" --base_url "${apiBaseUrl}"`, + go: `go install github.com/Elydora-Infrastructure/Elydora-Go-SDK/cmd/elydora@latest && elydora install --agent ${integration.agentFlag} --org_id "${creds.orgId}" --agent_id "${creds.agentId}" --private_key "${creds.privateKey}" --kid "${creds.kid}" --token "${apiToken ?? ''}" --base-url "${apiBaseUrl}"`, }); // SDK step-by-step tutorial (used for unsupported / SDK integrations) @@ -401,7 +401,7 @@ export const client = new ElydoraClient({ kid: '${creds.kid}', baseUrl: '${apiBaseUrl}', }); -client.setToken('${apiToken ?? authToken ?? ''}'); +client.setToken('${apiToken ?? ''}'); client.prevChainHash = readChainState(); export async function recordOperation({ operationType, subject, action, payload, verify = false }) { @@ -792,7 +792,7 @@ export const client = new ElydoraClient({ kid: '${creds.kid}', baseUrl: '${apiBaseUrl}', }); -client.setToken('${apiToken ?? authToken ?? ''}'); +client.setToken('${apiToken ?? ''}'); client.prevChainHash = readChainState(); export async function recordOperation({ operationType, subject, action, payload, verify = false }) { diff --git a/packages/console/src/lib/api.ts b/packages/console/src/lib/api.ts index b581997..1889401 100644 --- a/packages/console/src/lib/api.ts +++ b/packages/console/src/lib/api.ts @@ -18,10 +18,6 @@ import type { CreateExportRequest, CreateExportResponse, GetExportResponse, - AuthLoginResponse, - AuthRegisterResponse, - AuthMeResponse, - AuthRefreshResponse, IssueTokenResponse, JWKSResponse, ErrorResponse, @@ -42,34 +38,26 @@ class ApiError extends Error { } } -function getAuthToken(): string | null { - if (typeof window === 'undefined') return null; - return localStorage.getItem('elydora_token'); -} - async function request( path: string, options: RequestInit = {}, ): Promise { - const token = getAuthToken(); const headers: Record = { 'Content-Type': 'application/json', - ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(options.headers as Record ?? {}), }; const response = await fetch(`${API_BASE_URL}${path}`, { ...options, headers, + credentials: 'include', }); if (!response.ok) { - // On 401, clear stored tokens and redirect to login + // On 401, redirect to login (session expired or not authenticated) if (response.status === 401 && typeof window !== 'undefined') { - localStorage.removeItem('elydora_token'); - localStorage.removeItem('elydora_display_name'); - localStorage.removeItem('elydora_email'); window.location.href = '/login'; + return undefined as T; } let errorBody: ErrorResponse | null = null; @@ -213,11 +201,10 @@ export const api = { }, async download(exportId: string): Promise { - const token = getAuthToken(); const response = await fetch( `${API_BASE_URL}/v1/exports/${encodeURIComponent(exportId)}/download`, { - headers: token ? { Authorization: `Bearer ${token}` } : {}, + credentials: 'include', }, ); if (!response.ok) { @@ -234,30 +221,6 @@ export const api = { }, auth: { - login(email: string, password: string): Promise { - return request('/v1/auth/login', { - method: 'POST', - body: JSON.stringify({ email, password }), - }); - }, - - register(email: string, password: string, displayName: string, orgName: string): Promise { - return request('/v1/auth/register', { - method: 'POST', - body: JSON.stringify({ email, password, display_name: displayName, org_name: orgName }), - }); - }, - - me(): Promise { - return request('/v1/auth/me'); - }, - - refresh(): Promise { - return request('/v1/auth/refresh', { - method: 'POST', - }); - }, - issueToken(ttlSeconds: number | null): Promise { return request('/v1/auth/token', { method: 'POST', diff --git a/packages/console/src/lib/auth-client.ts b/packages/console/src/lib/auth-client.ts new file mode 100644 index 0000000..5956b57 --- /dev/null +++ b/packages/console/src/lib/auth-client.ts @@ -0,0 +1,11 @@ +import { createAuthClient } from 'better-auth/react'; +import { organizationClient } from 'better-auth/client/plugins'; + +export const authClient = createAuthClient({ + baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8787', + plugins: [ + organizationClient(), + ], +}); + +export const { signIn, signUp, signOut, useSession } = authClient; diff --git a/packages/console/src/lib/auth.tsx b/packages/console/src/lib/auth.tsx index c934c9c..3df8120 100644 --- a/packages/console/src/lib/auth.tsx +++ b/packages/console/src/lib/auth.tsx @@ -1,101 +1,83 @@ 'use client'; -import { createContext, useContext, useState, useEffect, useCallback } from 'react'; +import { createContext, useContext, useMemo, useCallback } from 'react'; import type { ReactNode } from 'react'; +import { useSession, signOut } from '@/lib/auth-client'; import type { RbacRole } from '@elydora/shared'; interface AuthUser { + id: string; sub: string; org_id: string; role: RbacRole; display_name?: string; email?: string; + onboarding_completed: boolean; } interface AuthContextValue { user: AuthUser | null; - token: string | null; isAuthenticated: boolean; isLoading: boolean; - login: (token: string, user?: { display_name?: string; email?: string }) => void; logout: () => void; + isAdminRole: boolean; + canManageMembers: boolean; + canManageAgents: boolean; + canViewAudit: boolean; } const AuthContext = createContext(null); -function decodeJWTPayload(token: string): AuthUser | null { - try { - const parts = token.split('.'); - if (parts.length !== 3) return null; - // base64url decode - let base64 = parts[1]!.replace(/-/g, '+').replace(/_/g, '/'); - while (base64.length % 4 !== 0) base64 += '='; - const json = atob(base64); - const payload = JSON.parse(json); - // Check expiration - const nowSeconds = Math.floor(Date.now() / 1000); - if (typeof payload.exp === 'number' && payload.exp < nowSeconds) return null; - if (!payload.sub || !payload.org_id || !payload.role) return null; - return { sub: payload.sub, org_id: payload.org_id, role: payload.role }; - } catch { - return null; - } -} - export function AuthProvider({ children }: { children: ReactNode }) { - const [user, setUser] = useState(null); - const [token, setToken] = useState(null); - const [isLoading, setIsLoading] = useState(true); + const { data: session, isPending } = useSession(); - useEffect(() => { - const stored = localStorage.getItem('elydora_token'); - if (stored) { - const decoded = decodeJWTPayload(stored); - if (decoded) { - setToken(stored); - // Try to fetch full user profile from localStorage - const displayName = localStorage.getItem('elydora_display_name'); - const email = localStorage.getItem('elydora_email'); - if (displayName) decoded.display_name = displayName; - if (email) decoded.email = email; - setUser({ ...decoded }); - } else { - // Token expired or invalid, clear it - localStorage.removeItem('elydora_token'); - localStorage.removeItem('elydora_display_name'); - localStorage.removeItem('elydora_email'); - } - } - setIsLoading(false); - }, []); + const user: AuthUser | null = useMemo(() => { + if (!session?.user) return null; - const login = useCallback((newToken: string, userInfo?: { display_name?: string; email?: string }) => { - localStorage.setItem('elydora_token', newToken); - const decoded = decodeJWTPayload(newToken); - if (decoded) { - if (userInfo?.display_name) { - decoded.display_name = userInfo.display_name; - localStorage.setItem('elydora_display_name', userInfo.display_name); - } - if (userInfo?.email) { - decoded.email = userInfo.email; - localStorage.setItem('elydora_email', userInfo.email); - } - setUser(decoded); - } - setToken(newToken); - }, []); + const sessionUser = session.user as Record; + const sessionData = session.session as Record; - const logout = useCallback(() => { - localStorage.removeItem('elydora_token'); - localStorage.removeItem('elydora_display_name'); - localStorage.removeItem('elydora_email'); - setUser(null); - setToken(null); + const orgId = + (sessionData.activeOrganizationId as string) ?? + (sessionUser.org_id as string) ?? + ''; + const role = + (sessionUser.role as RbacRole) ?? 'readonly_investigator'; + + return { + id: session.user.id, + sub: session.user.id, + org_id: orgId, + role, + display_name: session.user.name || undefined, + email: session.user.email || undefined, + onboarding_completed: !!(sessionUser.onboarding_completed), + }; + }, [session]); + + const isAdminRole = user?.role === 'org_owner' || user?.role === 'security_admin'; + const canManageMembers = isAdminRole; + const canManageAgents = isAdminRole || user?.role === 'integration_engineer'; + const canViewAudit = isAdminRole || user?.role === 'compliance_auditor'; + + const logout = useCallback(async () => { + await signOut(); + window.location.href = '/login'; }, []); return ( - + {children} ); diff --git a/packages/server/package-lock.json b/packages/server/package-lock.json index 913bb6e..f707d8a 100644 --- a/packages/server/package-lock.json +++ b/packages/server/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.0.0", "@hono/node-server": "^1.13.0", + "better-auth": "^1.5.5", "bullmq": "^5.0.0", "dotenv": "^16.0.0", "hono": "^4.7.0", @@ -874,6 +875,134 @@ "node": ">=18.0.0" } }, + "node_modules/@better-auth/core": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.5.6.tgz", + "integrity": "sha512-Ez9DZdIMFyxHremmoLz1emFPGNQomDC1jqqBPnZ6Ci+6TiGN3R9w/Y03cJn6I8r1ycKgOzeVMZtJ/erOZ27Gsw==", + "license": "MIT", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.39.0", + "@standard-schema/spec": "^1.1.0", + "zod": "^4.3.6" + }, + "peerDependencies": { + "@better-auth/utils": "0.3.1", + "@better-fetch/fetch": "1.1.21", + "@cloudflare/workers-types": ">=4", + "@opentelemetry/api": "^1.9.0", + "better-call": "1.3.2", + "jose": "^6.1.0", + "kysely": "^0.28.5", + "nanostores": "^1.0.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/@better-auth/drizzle-adapter": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.5.6.tgz", + "integrity": "sha512-VfFFmaoFw3ug12SiSuIwzrMoHyIVmkMGWm9gZ4sXdYYVX4HboCL4m3fjzOhppcmK5OGatRuU+N1UX6wxCITcXw==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "1.5.6", + "@better-auth/utils": "^0.3.0", + "drizzle-orm": ">=0.41.0" + }, + "peerDependenciesMeta": { + "drizzle-orm": { + "optional": true + } + } + }, + "node_modules/@better-auth/kysely-adapter": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.5.6.tgz", + "integrity": "sha512-Fnf+h8WVKtw6lEOmVmiVVzDf3shJtM60AYf9XTnbdCeUd6MxN/KnaJZpkgtYnRs7a+nwtkVB+fg4lGETebGFXQ==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "1.5.6", + "@better-auth/utils": "^0.3.0", + "kysely": "^0.27.0 || ^0.28.0" + }, + "peerDependenciesMeta": { + "kysely": { + "optional": true + } + } + }, + "node_modules/@better-auth/memory-adapter": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.5.6.tgz", + "integrity": "sha512-rS7ZsrIl5uvloUgNN0u9LOZJMMXnsZXVdUZ3MrTBKWM2KpoJjzPr9yN3Szyma5+0V7SltnzSGHPkYj2bEzzmlA==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "1.5.6", + "@better-auth/utils": "^0.3.0" + } + }, + "node_modules/@better-auth/mongo-adapter": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.5.6.tgz", + "integrity": "sha512-6+M3MS2mor8fTUV3EI1FBLP0cs6QfbN+Ovx9+XxR/GdfKIBoNFzmPEPRbdGt+ft6PvrITsUm+T70+kkHgVSP6w==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "1.5.6", + "@better-auth/utils": "^0.3.0", + "mongodb": "^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "mongodb": { + "optional": true + } + } + }, + "node_modules/@better-auth/prisma-adapter": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.5.6.tgz", + "integrity": "sha512-UxY9vQJs1Tt+O+T2YQnseDMlWmUSQvFZSBb5YiFRg7zcm+TEzujh4iX2/csA0YiZptLheovIuVWTP9nriewEBA==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "1.5.6", + "@better-auth/utils": "^0.3.0", + "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", + "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "@prisma/client": { + "optional": true + }, + "prisma": { + "optional": true + } + } + }, + "node_modules/@better-auth/telemetry": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.5.6.tgz", + "integrity": "sha512-yXC7NSxnIFlxDkGdpD7KA+J9nqIQAPCJKe77GoaC5bWoe/DALo1MYorZfTgOafS7wrslNtsPT4feV/LJi1ubqQ==", + "license": "MIT", + "dependencies": { + "@better-auth/utils": "0.3.1", + "@better-fetch/fetch": "1.1.21" + }, + "peerDependencies": { + "@better-auth/core": "1.5.6" + } + }, + "node_modules/@better-auth/utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.3.1.tgz", + "integrity": "sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg==", + "license": "MIT" + }, + "node_modules/@better-fetch/fetch": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.1.21.tgz", + "integrity": "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -1412,6 +1541,49 @@ "win32" ] }, + "node_modules/@noble/ciphers": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", + "integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz", + "integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@smithy/abort-controller": { "version": "4.2.10", "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.10.tgz", @@ -2144,6 +2316,12 @@ "node": ">=18.0.0" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.35", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.35.tgz", @@ -2173,6 +2351,131 @@ "pg-types": "^2.2.0" } }, + "node_modules/better-auth": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.5.6.tgz", + "integrity": "sha512-QSpJTqaT1XVfWRQe/fm3PgeuwOIlz1nWX/Dx7nsHStJ382bLzmDbQk2u7IT0IJ6wS5SRxfqEE1Ev9TXontgyAQ==", + "license": "MIT", + "dependencies": { + "@better-auth/core": "1.5.6", + "@better-auth/drizzle-adapter": "1.5.6", + "@better-auth/kysely-adapter": "1.5.6", + "@better-auth/memory-adapter": "1.5.6", + "@better-auth/mongo-adapter": "1.5.6", + "@better-auth/prisma-adapter": "1.5.6", + "@better-auth/telemetry": "1.5.6", + "@better-auth/utils": "0.3.1", + "@better-fetch/fetch": "1.1.21", + "@noble/ciphers": "^2.1.1", + "@noble/hashes": "^2.0.1", + "better-call": "1.3.2", + "defu": "^6.1.4", + "jose": "^6.1.3", + "kysely": "^0.28.12", + "nanostores": "^1.1.1", + "zod": "^4.3.6" + }, + "peerDependencies": { + "@lynx-js/react": "*", + "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", + "@sveltejs/kit": "^2.0.0", + "@tanstack/react-start": "^1.0.0", + "@tanstack/solid-start": "^1.0.0", + "better-sqlite3": "^12.0.0", + "drizzle-kit": ">=0.31.4", + "drizzle-orm": ">=0.41.0", + "mongodb": "^6.0.0 || ^7.0.0", + "mysql2": "^3.0.0", + "next": "^14.0.0 || ^15.0.0 || ^16.0.0", + "pg": "^8.0.0", + "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0", + "solid-js": "^1.0.0", + "svelte": "^4.0.0 || ^5.0.0", + "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", + "vue": "^3.0.0" + }, + "peerDependenciesMeta": { + "@lynx-js/react": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@sveltejs/kit": { + "optional": true + }, + "@tanstack/react-start": { + "optional": true + }, + "@tanstack/solid-start": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "drizzle-kit": { + "optional": true + }, + "drizzle-orm": { + "optional": true + }, + "mongodb": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "next": { + "optional": true + }, + "pg": { + "optional": true + }, + "prisma": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "solid-js": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vitest": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/better-call": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.3.2.tgz", + "integrity": "sha512-4cZIfrerDsNTn3cm+MhLbUePN0gdwkhSXEuG7r/zuQ8c/H7iU0/jSK5TD3FW7U0MgKHce/8jGpPYNO4Ve+4NBw==", + "license": "MIT", + "dependencies": { + "@better-auth/utils": "^0.3.1", + "@better-fetch/fetch": "^1.1.21", + "rou3": "^0.7.12", + "set-cookie-parser": "^3.0.1" + }, + "peerDependencies": { + "zod": "^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/bowser": { "version": "2.14.1", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", @@ -2262,6 +2565,12 @@ } } }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "license": "MIT" + }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", @@ -2414,6 +2723,24 @@ "url": "https://opencollective.com/ioredis" } }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/kysely": { + "version": "0.28.14", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.14.tgz", + "integrity": "sha512-SU3lgh0rPvq7upc6vvdVrCsSMUG1h3ChvHVOY7wJ2fw4C9QEB7X3d5eyYEyULUX7UQtxZJtZXGuT6U2US72UYA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/lodash.defaults": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", @@ -2472,6 +2799,21 @@ "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" } }, + "node_modules/nanostores": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.2.0.tgz", + "integrity": "sha512-F0wCzbsH80G7XXo0Jd9/AVQC7ouWY6idUCTnMwW5t/Rv9W8qmO6endavDwg7TNp5GbugwSukFMVZqzPSrSMndg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, "node_modules/node-abort-controller": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", @@ -2673,6 +3015,12 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/rou3": { + "version": "0.7.12", + "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.7.12.tgz", + "integrity": "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==", + "license": "MIT" + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -2685,6 +3033,12 @@ "node": ">=10" } }, + "node_modules/set-cookie-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "license": "MIT" + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -2780,6 +3134,15 @@ "engines": { "node": ">=0.4" } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/packages/server/package.json b/packages/server/package.json index 0fb2b95..33d30d2 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.0.0", + "better-auth": "^1.5.5", "@hono/node-server": "^1.13.0", "bullmq": "^5.0.0", "dotenv": "^16.0.0", diff --git a/packages/server/src/i18n/messages.ts b/packages/server/src/i18n/messages.ts index 9b3c0f3..4fd772d 100644 --- a/packages/server/src/i18n/messages.ts +++ b/packages/server/src/i18n/messages.ts @@ -89,42 +89,6 @@ const messages: Record> = { en: 'Empty Bearer token', zh: 'Bearer \u4ee4\u724c\u4e3a\u7a7a', }, - 'auth.malformedJwt': { - en: 'Malformed JWT: expected 3 parts', - zh: 'JWT \u683c\u5f0f\u9519\u8bef\uff1a\u5e94\u5305\u542b 3 \u4e2a\u90e8\u5206', - }, - 'auth.invalidSignature': { - en: 'Invalid JWT signature', - zh: 'JWT \u7b7e\u540d\u65e0\u6548', - }, - 'auth.malformedPayload': { - en: 'Malformed JWT payload', - zh: 'JWT \u8d1f\u8f7d\u683c\u5f0f\u9519\u8bef', - }, - 'auth.missingSub': { - en: 'JWT missing required "sub" claim', - zh: 'JWT \u7f3a\u5c11\u5fc5\u9700\u7684 "sub" \u58f0\u660e', - }, - 'auth.missingOrgId': { - en: 'JWT missing required "org_id" claim', - zh: 'JWT \u7f3a\u5c11\u5fc5\u9700\u7684 "org_id" \u58f0\u660e', - }, - 'auth.missingRole': { - en: 'JWT missing required "role" claim', - zh: 'JWT \u7f3a\u5c11\u5fc5\u9700\u7684 "role" \u58f0\u660e', - }, - 'auth.missingExp': { - en: 'JWT missing required "exp" claim', - zh: 'JWT \u7f3a\u5c11\u5fc5\u9700\u7684 "exp" \u58f0\u660e', - }, - 'auth.expired': { - en: 'JWT has expired', - zh: 'JWT \u5df2\u8fc7\u671f', - }, - 'auth.unsupportedAlgorithm': { - en: 'Unsupported JWT algorithm. Only HS256 is accepted.', - zh: '\u4e0d\u652f\u6301\u7684 JWT \u7b97\u6cd5\u3002\u4ec5\u63a5\u53d7 HS256\u3002', - }, 'auth.ttlMustBePositive': { en: 'ttl_seconds must be a positive number', zh: 'ttl_seconds \u5fc5\u987b\u4e3a\u6b63\u6570', diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 1e11964..02fec73 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -48,7 +48,7 @@ const queue = new BullMQAdapter({ // --------------------------------------------------------------------------- const requiredSecrets = [ - { name: 'JWT_SECRET', value: process.env.JWT_SECRET }, + { name: 'BETTER_AUTH_SECRET', value: process.env.BETTER_AUTH_SECRET }, { name: 'ELYDORA_SIGNING_KEY', value: process.env.ELYDORA_SIGNING_KEY }, ]; for (const { name, value } of requiredSecrets) { @@ -70,7 +70,8 @@ const env: Env = { ENVIRONMENT: process.env.ENVIRONMENT ?? 'production', API_VERSION: process.env.API_VERSION ?? 'v1', PROTOCOL_VERSION: process.env.PROTOCOL_VERSION ?? '1.0', - JWT_SECRET: process.env.JWT_SECRET!, + BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET!, + BETTER_AUTH_URL: process.env.BETTER_AUTH_URL ?? 'http://localhost:8787', ELYDORA_SIGNING_KEY: process.env.ELYDORA_SIGNING_KEY!, ALLOWED_ORIGINS: process.env.ALLOWED_ORIGINS ?? '', TSA_URL: process.env.TSA_URL, @@ -89,17 +90,14 @@ app.use('/*', async (c, next) => { await next(); }); -// CORS +// CORS — always restrict to the configured allowlist app.use( '/*', cors({ origin: (origin, c) => { - if (c.env.ENVIRONMENT === 'production') { - const raw = c.env.ALLOWED_ORIGINS ?? ''; - const allowedOrigins = raw.split(',').map((s: string) => s.trim()).filter(Boolean); - return allowedOrigins.includes(origin) ? origin : ''; - } - return origin; + const raw = c.env.ALLOWED_ORIGINS ?? 'http://localhost:3000,http://localhost:8787'; + const allowedOrigins = raw.split(',').map((s: string) => s.trim()).filter(Boolean); + return allowedOrigins.includes(origin) ? origin : ''; }, allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], allowHeaders: [ diff --git a/packages/server/src/lib/auth.ts b/packages/server/src/lib/auth.ts new file mode 100644 index 0000000..e828c9e --- /dev/null +++ b/packages/server/src/lib/auth.ts @@ -0,0 +1,308 @@ +/** + * Better Auth configuration for ElydoraOpenSource. + * + * Creates a Better Auth instance backed by PostgreSQL. The instance is + * configured to: + * + * - Use the existing `users` table via field-mapping (no schema change) + * - Support legacy PBKDF2-SHA256 password hashes alongside Better Auth's + * default scrypt so existing users can sign in without re-hashing + * - Expose bearer-token auth for API clients + * - Provide organization support with Elydora's 5-role RBAC hierarchy + * - Provide admin user-management endpoints + */ + +import { betterAuth } from 'better-auth'; +import { verifyPassword as verifyScryptPassword } from 'better-auth/crypto'; +import { bearer } from 'better-auth/plugins/bearer'; +import { organization } from 'better-auth/plugins/organization'; +import { admin } from 'better-auth/plugins/admin'; +import { createAccessControl } from 'better-auth/plugins/access'; + +// --------------------------------------------------------------------------- +// Legacy PBKDF2 password verification (matches auth-service.ts format) +// Format: pbkdf2:iterations:salt_hex:hash_hex +// --------------------------------------------------------------------------- + +async function verifyLegacyPbkdf2(password: string, stored: string): Promise { + const parts = stored.split(':'); + if (parts.length !== 4 || parts[0] !== 'pbkdf2') return false; + + const iterations = parseInt(parts[1]!, 10); + const saltHex = parts[2]!; + const storedHashHex = parts[3]!; + + const salt = new Uint8Array(saltHex.match(/.{2}/g)!.map((b) => parseInt(b, 16))); + const encoder = new TextEncoder(); + const keyMaterial = await crypto.subtle.importKey( + 'raw', + encoder.encode(password), + 'PBKDF2', + false, + ['deriveBits'], + ); + const bits = await crypto.subtle.deriveBits( + { name: 'PBKDF2', salt, iterations, hash: 'SHA-256' }, + keyMaterial, + 256, + ); + const hashHex = Array.from(new Uint8Array(bits)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + + // Constant-time comparison + if (hashHex.length !== storedHashHex.length) return false; + let result = 0; + for (let i = 0; i < hashHex.length; i++) { + result |= hashHex.charCodeAt(i) ^ storedHashHex.charCodeAt(i); + } + return result === 0; +} + +// --------------------------------------------------------------------------- +// RBAC access control — maps Elydora's 5-role hierarchy to Better Auth +// --------------------------------------------------------------------------- + +const elydoraAc = createAccessControl({ + organization: ['create', 'read', 'update', 'delete'], + member: ['create', 'read', 'update', 'delete'], + invitation: ['create', 'cancel'], + agent: ['create', 'read', 'update', 'freeze', 'unfreeze', 'revoke', 'delete'], + operation: ['create', 'read'], + audit: ['read', 'export'], + webhook: ['create', 'read', 'update', 'delete'], +} as const); + +const orgOwnerRole = elydoraAc.newRole({ + organization: ['create', 'read', 'update', 'delete'], + member: ['create', 'read', 'update', 'delete'], + invitation: ['create', 'cancel'], + agent: ['create', 'read', 'update', 'freeze', 'unfreeze', 'revoke', 'delete'], + operation: ['create', 'read'], + audit: ['read', 'export'], + webhook: ['create', 'read', 'update', 'delete'], +}); + +const securityAdminRole = elydoraAc.newRole({ + organization: ['read', 'update'], + member: ['create', 'read', 'update', 'delete'], + invitation: ['create', 'cancel'], + agent: ['create', 'read', 'update', 'freeze', 'unfreeze', 'revoke', 'delete'], + operation: ['create', 'read'], + audit: ['read', 'export'], + webhook: ['create', 'read', 'update', 'delete'], +}); + +const complianceAuditorRole = elydoraAc.newRole({ + organization: ['read'], + member: ['read'], + agent: ['read'], + operation: ['read'], + audit: ['read', 'export'], + webhook: ['read'], +}); + +const integrationEngineerRole = elydoraAc.newRole({ + organization: ['read'], + member: ['read'], + agent: ['create', 'read', 'update'], + operation: ['create', 'read'], + audit: ['read'], + webhook: ['create', 'read', 'update', 'delete'], +}); + +const readonlyInvestigatorRole = elydoraAc.newRole({ + organization: ['read'], + member: ['read'], + agent: ['read'], + operation: ['read'], + audit: ['read'], + webhook: ['read'], +}); + +// --------------------------------------------------------------------------- +// Singleton — creates a Better Auth instance for the server process +// --------------------------------------------------------------------------- + +export function createAuth(databaseUrl: string, secret: string, baseUrl: string, allowedOrigins: string) { + return betterAuth({ + database: { + type: 'postgres', + url: databaseUrl, + }, + secret, + baseURL: baseUrl, + trustedOrigins: allowedOrigins + ? allowedOrigins.split(',').map((s: string) => s.trim()).filter(Boolean) + : [], + + emailAndPassword: { + enabled: true, + minPasswordLength: 8, + maxPasswordLength: 256, + password: { + verify: async ({ hash, password }) => { + // Handle legacy PBKDF2 hashes from the existing auth system + if (hash.startsWith('pbkdf2:')) { + return verifyLegacyPbkdf2(password, hash); + } + // For BA-native scrypt hashes (new registrations, password resets) + return verifyScryptPassword({ hash, password }); + }, + }, + }, + + user: { + modelName: 'users', + fields: { + name: 'display_name', + email: 'email', + emailVerified: 'email_verified', + image: 'image', + createdAt: 'created_at', + updatedAt: 'updated_at', + }, + additionalFields: { + org_id: { + type: 'string', + required: false, + }, + role: { + type: 'string', + required: true, + defaultValue: 'org_owner', + }, + status: { + type: 'string', + required: true, + defaultValue: 'active', + }, + password_hash: { + type: 'string', + required: false, + }, + onboarding_completed: { + type: 'number', + required: true, + defaultValue: 0, + }, + }, + }, + + databaseHooks: { + user: { + create: { + before: async (user, _context) => { + const now = new Date(); + return { + data: { + ...user, + createdAt: now, + updatedAt: now, + role: 'org_owner', + }, + }; + }, + after: async (_user) => { + // No-op: new users start with org_id = NULL and + // onboarding_completed = 0 (DB default). The onboarding + // flow handles org creation. + }, + }, + }, + }, + + account: { + modelName: 'ba_accounts', + fields: { + createdAt: 'created_at', + updatedAt: 'updated_at', + userId: 'user_id', + providerId: 'provider_id', + accountId: 'account_id', + accessToken: 'access_token', + refreshToken: 'refresh_token', + idToken: 'id_token', + accessTokenExpiresAt: 'access_token_expires_at', + refreshTokenExpiresAt: 'refresh_token_expires_at', + }, + }, + + verification: { + modelName: 'ba_verifications', + fields: { + createdAt: 'created_at', + updatedAt: 'updated_at', + expiresAt: 'expires_at', + }, + }, + + session: { + modelName: 'ba_sessions', + expiresIn: 604800, // 7 days in seconds + updateAge: 86400, // refresh session if older than 1 day + fields: { + createdAt: 'created_at', + updatedAt: 'updated_at', + userId: 'user_id', + expiresAt: 'expires_at', + ipAddress: 'ip_address', + userAgent: 'user_agent', + }, + }, + + advanced: { + cookiePrefix: 'elydora', + defaultCookieAttributes: { + httpOnly: true, + secure: true, + sameSite: 'lax' as const, + path: '/', + }, + }, + + plugins: [ + bearer(), + organization({ + ac: elydoraAc, + creatorRole: 'org_owner', + roles: { + org_owner: orgOwnerRole, + security_admin: securityAdminRole, + compliance_auditor: complianceAuditorRole, + integration_engineer: integrationEngineerRole, + readonly_investigator: readonlyInvestigatorRole, + }, + schema: { + organization: { + modelName: 'ba_organizations', + fields: { + createdAt: 'created_at', + updatedAt: 'updated_at', + }, + }, + member: { + modelName: 'ba_members', + fields: { + createdAt: 'created_at', + organizationId: 'organization_id', + userId: 'user_id', + }, + }, + invitation: { + modelName: 'ba_invitations', + fields: { + createdAt: 'created_at', + organizationId: 'organization_id', + inviterId: 'inviter_id', + expiresAt: 'expires_at', + }, + }, + }, + }), + admin(), + ], + }); +} + +export type ElydoraAuth = ReturnType; diff --git a/packages/server/src/middleware/auth.ts b/packages/server/src/middleware/auth.ts index 1f00656..0a6871d 100644 --- a/packages/server/src/middleware/auth.ts +++ b/packages/server/src/middleware/auth.ts @@ -1,147 +1,62 @@ /** - * JWT authentication middleware. + * Better Auth authentication middleware. * - * Verifies Bearer tokens in the Authorization header using HMAC-SHA256. - * On success, extracts `org_id`, `role`, and `sub` (actor) claims and - * stores them in the Hono context variables for downstream handlers. + * Validates requests via Better Auth sessions (cookie or bearer token). + * On success, extracts `org_id`, `role`, and user `id` (actor) from the + * session and stores them in the Hono context variables for downstream + * handlers. * - * Token structure (payload): - * { - * "sub": "user_abc123", // actor identifier - * "org_id": "org_acme", // organization scope - * "role": "org_owner", // RBAC role - * "iat": 1700000000, // issued-at (seconds) - * "exp": 1700003600 // expiration (seconds) - * } + * Supports: + * - Session cookies (browser / console) + * - Bearer tokens via Better Auth's bearer plugin (SDK clients) */ import type { MiddlewareHandler } from 'hono'; import type { RbacRole } from '../shared/index.js'; import type { Env, AppVariables } from '../types.js'; import { AppError } from './error-handler.js'; -import { base64urlDecode } from '../utils/crypto.js'; - -/** Minimal JWT payload shape we require. */ -interface JWTPayload { - sub: string; - org_id: string; - role: RbacRole; - iat: number; - exp: number; -} - -/** - * Verify a JWT using HMAC-SHA256 and return its payload. - */ -async function verifyJWT(token: string, secret: string): Promise { - const parts = token.split('.'); - if (parts.length !== 3) { - throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.malformedJwt' }); - } - - const headerB64 = parts[0]!; - const payloadB64 = parts[1]!; - const signatureB64 = parts[2]!; - - // Validate JWT header — only HS256 is accepted - try { - const headerBytes = base64urlDecode(headerB64); - const header = JSON.parse(new TextDecoder().decode(headerBytes)) as { alg?: string; typ?: string }; - if (header.alg !== 'HS256') { - throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.unsupportedAlgorithm' }); - } - } catch (e) { - if (e instanceof AppError) throw e; - throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.malformedJwt' }); - } - - // Import the HMAC key - const encoder = new TextEncoder(); - const keyData = encoder.encode(secret); - const cryptoKey = await crypto.subtle.importKey( - 'raw', - keyData, - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['verify'], - ); - - // Verify signature over "header.payload" - const signedInput = encoder.encode(`${headerB64}.${payloadB64}`); - const signatureBytes = base64urlDecode(signatureB64); - - const valid = await crypto.subtle.verify('HMAC', cryptoKey, signatureBytes as unknown as Uint8Array, signedInput as unknown as Uint8Array); - - if (!valid) { - throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.invalidSignature' }); - } - - // Decode and validate payload - const payloadBytes = base64urlDecode(payloadB64); - const payloadJson = new TextDecoder().decode(payloadBytes); - let payload: JWTPayload; - - try { - payload = JSON.parse(payloadJson); - } catch { - throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.malformedPayload' }); - } - - // Validate required claims - if (!payload.sub || typeof payload.sub !== 'string') { - throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.missingSub' }); - } - if (!payload.org_id || typeof payload.org_id !== 'string') { - throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.missingOrgId' }); - } - if (!payload.role || typeof payload.role !== 'string') { - throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.missingRole' }); - } - - // Check expiration - const nowSeconds = Math.floor(Date.now() / 1000); - if (typeof payload.exp !== 'number') { - throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.missingExp' }); - } - if (payload.exp < nowSeconds) { - throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.expired' }); - } - - return payload; -} +import { createAuth } from '../lib/auth.js'; /** * Authentication middleware. * - * Extracts the Bearer token, verifies it, and sets org_id, role, and - * actor on the context. Should be applied to all routes that require - * authentication. + * Uses Better Auth's getSession to validate the request via session + * cookie or bearer token. Sets org_id, role, and actor on the context. */ export const authMiddleware: MiddlewareHandler<{ Bindings: Env; Variables: AppVariables; }> = async (c, next) => { const authHeader = c.req.header('Authorization'); - if (!authHeader) { throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.missingHeader' }); } - if (!authHeader.startsWith('Bearer ')) { - throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.bearerRequired' }); + let session: Awaited['api']['getSession']>>; + + try { + const betterAuthInstance = createAuth( + process.env.DATABASE_URL!, + c.env.BETTER_AUTH_SECRET, + c.env.BETTER_AUTH_URL, + c.env.ALLOWED_ORIGINS, + ); + session = await betterAuthInstance.api.getSession({ + headers: c.req.raw.headers, + }); + } catch { + throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.invalidSession' }); } - const token = authHeader.slice(7).trim(); - - if (!token) { - throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.emptyToken' }); + if (!session) { + throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.invalidSession' }); } - const payload = await verifyJWT(token, c.env.JWT_SECRET); + const user = session.user as { id: string; org_id?: string; role?: string }; - c.set('org_id', payload.org_id); - c.set('role', payload.role); - c.set('actor', payload.sub); + c.set('org_id', user.org_id ?? ''); + c.set('role', ((user.role ?? 'readonly_investigator') as RbacRole)); + c.set('actor', user.id); await next(); }; diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts index 2b67958..6a33e2d 100644 --- a/packages/server/src/routes/auth.ts +++ b/packages/server/src/routes/auth.ts @@ -2,15 +2,17 @@ * Auth routes — registration, login, profile retrieval, and token refresh. * * /register and /login are public (no auth required). - * /me and /refresh require a valid JWT. + * /me, /refresh, and /token require a valid Better Auth session. + * + * Uses Better Auth for session management while preserving the existing + * API surface (endpoint paths and response formats). */ import { Hono } from 'hono'; import type { Env, AppVariables } from '../types.js'; import { authMiddleware } from '../middleware/auth.js'; -import { registerUser, loginUser, getMe, issueJWT, issueApiToken } from '../services/auth-service.js'; -import type { RbacRole } from '../shared/index.js'; -import { DEFAULT_JWT_TTL_SECONDS } from '../shared/index.js'; +import { getMe } from '../services/auth-service.js'; +import { createAuth } from '../lib/auth.js'; import { getMessage } from '../i18n/messages.js'; const auth = new Hono<{ Bindings: Env; Variables: AppVariables }>(); @@ -22,28 +24,111 @@ auth.post('/register', async (c) => { const body = await c.req.json(); const { email, password, display_name, org_name } = body; - const result = await registerUser( - c.env.ELYDORA_DB, - c.env.JWT_SECRET, - email, - password, - display_name, - org_name, + const betterAuthInstance = createAuth( + process.env.DATABASE_URL!, + c.env.BETTER_AUTH_SECRET, + c.env.BETTER_AUTH_URL, + c.env.ALLOWED_ORIGINS, ); - return c.json(result, 201); + // Use Better Auth to sign up the user + const signUpResult = await betterAuthInstance.api.signUpEmail({ + body: { + email, + password, + name: display_name || email.split('@')[0]!, + }, + }); + + if (!signUpResult) { + const lang = c.get('lang') ?? 'en'; + return c.json({ error: { code: 'VALIDATION_ERROR', message: getMessage('auth.emailAlreadyRegistered', lang), request_id: '' } }, 409); + } + + const user = signUpResult.user as { id: string; email: string; name: string; org_id?: string; role?: string; status?: string; createdAt?: unknown; updatedAt?: unknown }; + + // Create the organization via the database adapter (preserving existing schema) + const now = Date.now(); + const { generateUUIDv7 } = await import('../utils/uuid.js'); + const orgId = generateUUIDv7(); + const resolvedOrgName = org_name || `${user.name}'s Organization`; + + await c.env.ELYDORA_DB.batch([ + c.env.ELYDORA_DB + .prepare( + 'INSERT INTO organizations (org_id, name, created_at, updated_at) VALUES (?, ?, ?, ?)', + ) + .bind(orgId, resolvedOrgName, now, now), + c.env.ELYDORA_DB + .prepare( + 'UPDATE users SET org_id = ?, role = ?, status = ? WHERE user_id = ?', + ) + .bind(orgId, 'org_owner', 'active', user.id), + ]); + + // Return the session token + const token = signUpResult.token ?? ''; + + return c.json({ + user: { + user_id: user.id, + org_id: orgId, + email: user.email, + display_name: user.name, + role: 'org_owner', + status: 'active', + created_at: now, + updated_at: now, + }, + organization: { + org_id: orgId, + name: resolvedOrgName, + created_at: now, + updated_at: now, + }, + token, + }, 201); }); // --------------------------------------------------------------------------- -// POST /v1/auth/login — Authenticate and receive a JWT +// POST /v1/auth/login — Authenticate and receive a session token // --------------------------------------------------------------------------- auth.post('/login', async (c) => { const body = await c.req.json(); const { email, password } = body; - const result = await loginUser(c.env.ELYDORA_DB, c.env.JWT_SECRET, email, password); + const betterAuthInstance = createAuth( + process.env.DATABASE_URL!, + c.env.BETTER_AUTH_SECRET, + c.env.BETTER_AUTH_URL, + c.env.ALLOWED_ORIGINS, + ); - return c.json(result, 200); + const signInResult = await betterAuthInstance.api.signInEmail({ + body: { email, password }, + }); + + if (!signInResult) { + const lang = c.get('lang') ?? 'en'; + return c.json({ error: { code: 'UNAUTHORIZED', message: getMessage('auth.invalidCredentials', lang), request_id: '' } }, 401); + } + + const user = signInResult.user as { id: string; email: string; name: string; org_id?: string; role?: string; status?: string; created_at?: number; updated_at?: number }; + const token = signInResult.token ?? ''; + + return c.json({ + user: { + user_id: user.id, + org_id: user.org_id ?? '', + email: user.email, + display_name: user.name, + role: user.role ?? 'org_owner', + status: user.status ?? 'active', + created_at: user.created_at ?? 0, + updated_at: user.updated_at ?? 0, + }, + token, + }, 200); }); // --------------------------------------------------------------------------- @@ -57,21 +142,29 @@ auth.get('/me', authMiddleware, async (c) => { }); // --------------------------------------------------------------------------- -// POST /v1/auth/refresh — Issue a new JWT (auth required) +// POST /v1/auth/refresh — Issue a new session token (auth required) // --------------------------------------------------------------------------- auth.post('/refresh', authMiddleware, async (c) => { - const now = Date.now(); - const token = await issueJWT( - { - sub: c.get('actor'), - org_id: c.get('org_id'), - role: c.get('role') as RbacRole, - iat: Math.floor(now / 1000), - exp: Math.floor(now / 1000) + DEFAULT_JWT_TTL_SECONDS, - }, - c.env.JWT_SECRET, + const betterAuthInstance = createAuth( + process.env.DATABASE_URL!, + c.env.BETTER_AUTH_SECRET, + c.env.BETTER_AUTH_URL, + c.env.ALLOWED_ORIGINS, ); + // Get the current session and return its token — Better Auth handles + // session refresh automatically based on the updateAge setting + const session = await betterAuthInstance.api.getSession({ + headers: c.req.raw.headers, + }); + + if (!session) { + const lang = c.get('lang') ?? 'en'; + return c.json({ error: { code: 'UNAUTHORIZED', message: getMessage('auth.invalidSession', lang), request_id: '' } }, 401); + } + + const token = session.session?.token ?? ''; + return c.json({ token }, 200); }); @@ -89,15 +182,33 @@ auth.post('/token', authMiddleware, async (c) => { } } - const result = await issueApiToken( - c.get('actor'), - c.get('org_id'), - c.get('role') as RbacRole, - c.env.JWT_SECRET, - ttl_seconds ?? null, + // Get the current session token — Better Auth sessions have a fixed + // lifetime configured in the auth instance (7 days by default). + // The ttl_seconds parameter is accepted for API compatibility but + // session lifetime is governed by Better Auth's session config. + const betterAuthInstance = createAuth( + process.env.DATABASE_URL!, + c.env.BETTER_AUTH_SECRET, + c.env.BETTER_AUTH_URL, + c.env.ALLOWED_ORIGINS, ); - return c.json(result, 200); + const session = await betterAuthInstance.api.getSession({ + headers: c.req.raw.headers, + }); + + if (!session) { + const lang = c.get('lang') ?? 'en'; + return c.json({ error: { code: 'UNAUTHORIZED', message: getMessage('auth.invalidSession', lang), request_id: '' } }, 401); + } + + const sessionData = session.session as { token?: string; expiresAt?: Date }; + const token = sessionData.token ?? ''; + const expiresAt = sessionData.expiresAt + ? Math.floor(new Date(sessionData.expiresAt).getTime() / 1000) + : Math.floor(Date.now() / 1000) + 604800; + + return c.json({ token, expires_at: expiresAt }, 200); }); export { auth }; diff --git a/packages/server/src/routes/operations.ts b/packages/server/src/routes/operations.ts index 5313e92..f9fa638 100644 --- a/packages/server/src/routes/operations.ts +++ b/packages/server/src/routes/operations.ts @@ -16,7 +16,7 @@ const operations = new Hono<{ Bindings: Env; Variables: AppVariables }>(); // POST /v1/operations — Submit a signed EOR // --------------------------------------------------------------------------- // Operation submission uses the EOR's embedded signature for authentication -// rather than a Bearer JWT. The agent authenticates via its Ed25519 signature. +// rather than a Bearer token. The agent authenticates via its Ed25519 signature. // We still apply auth middleware to get the org context. operations.post( '/', diff --git a/packages/server/src/services/auth-service.ts b/packages/server/src/services/auth-service.ts index fbd276c..8bf70fa 100644 --- a/packages/server/src/services/auth-service.ts +++ b/packages/server/src/services/auth-service.ts @@ -1,14 +1,12 @@ /** - * Auth service — password hashing, JWT issuance, user registration, and login. + * Auth service — password hashing and user profile retrieval. * - * Uses PBKDF2-SHA256 via Web Crypto API for password hashing and - * HMAC-SHA256 for JWT issuance (matching the existing auth middleware). + * Authentication is handled by Better Auth (see lib/auth.ts). + * This service provides password utilities for legacy PBKDF2 hash + * verification and the getMe profile query. */ -import { base64urlEncode } from '../utils/crypto.js'; -import { generateUUIDv7 } from '../utils/uuid.js'; import { AppError } from '../middleware/error-handler.js'; -import { DEFAULT_JWT_TTL_SECONDS } from '../shared/index.js'; import type { RbacRole } from '../shared/index.js'; import type { Database } from '../adapters/interfaces.js'; @@ -34,18 +32,6 @@ export interface OrgRow { readonly updated_at: number; } -// --------------------------------------------------------------------------- -// JWT payload -// --------------------------------------------------------------------------- - -interface JWTPayload { - sub: string; - org_id: string; - role: RbacRole; - iat: number; - exp: number; -} - // --------------------------------------------------------------------------- // Password hashing (PBKDF2-SHA256 via Web Crypto) // Format: pbkdf2:100000:salt_hex:hash_hex @@ -110,199 +96,6 @@ export async function verifyPassword(password: string, stored: string): Promise< return result === 0; } -// --------------------------------------------------------------------------- -// JWT issuance (HMAC-SHA256, matching existing auth middleware verification) -// --------------------------------------------------------------------------- - -export async function issueJWT(payload: JWTPayload, secret: string): Promise { - const encoder = new TextEncoder(); - const header = { alg: 'HS256', typ: 'JWT' }; - const headerB64 = base64urlEncode(encoder.encode(JSON.stringify(header))); - const payloadB64 = base64urlEncode(encoder.encode(JSON.stringify(payload))); - - const signingInput = `${headerB64}.${payloadB64}`; - const key = await crypto.subtle.importKey( - 'raw', - encoder.encode(secret), - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['sign'], - ); - const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(signingInput)); - const signatureB64 = base64urlEncode(new Uint8Array(signature)); - - return `${headerB64}.${payloadB64}.${signatureB64}`; -} - -// --------------------------------------------------------------------------- -// Issue API token with custom TTL -// --------------------------------------------------------------------------- - -/** Maximum API token lifetime: 365 days. */ -const MAX_API_TOKEN_TTL = 365 * 24 * 60 * 60; - -export async function issueApiToken( - sub: string, - orgId: string, - role: RbacRole, - jwtSecret: string, - ttlSeconds: number | null, -): Promise<{ token: string; expires_at: number }> { - const now = Math.floor(Date.now() / 1000); - // Default to 90 days if no TTL specified; cap at 365 days - const effectiveTtl = ttlSeconds === null ? 90 * 24 * 60 * 60 : Math.min(ttlSeconds, MAX_API_TOKEN_TTL); - if (effectiveTtl <= 0) { - throw new AppError(400, 'VALIDATION_ERROR', { key: 'auth.ttlMustBePositive' }); - } - const exp = now + effectiveTtl; - const expiresAt = exp; - - const token = await issueJWT( - { sub, org_id: orgId, role, iat: now, exp }, - jwtSecret, - ); - - return { token, expires_at: expiresAt }; -} - -// --------------------------------------------------------------------------- -// Register user + org -// --------------------------------------------------------------------------- - -export async function registerUser( - db: Database, - jwtSecret: string, - email: string, - password: string, - displayName: string, - orgName: string, -): Promise<{ user: UserRow; organization: OrgRow; token: string }> { - // Validate - if (!email || !password || password.length < 8 || password.length > 128) { - throw new AppError(400, 'VALIDATION_ERROR', { key: 'auth.emailPasswordRequired' }); - } - - // Check email uniqueness - const existing = await db - .prepare('SELECT user_id FROM users WHERE email = ?') - .bind(email) - .first(); - - if (existing) { - throw new AppError(409, 'VALIDATION_ERROR', { key: 'auth.emailAlreadyRegistered' }); - } - - const now = Date.now(); - const orgId = generateUUIDv7(); - const userId = generateUUIDv7(); - const hash = await hashPassword(password); - - const resolvedDisplayName = displayName || email.split('@')[0]!; - const resolvedOrgName = orgName || `${resolvedDisplayName}'s Organization`; - - // Create org + user atomically in a single transaction - await db.batch([ - db - .prepare( - 'INSERT INTO organizations (org_id, name, created_at, updated_at) VALUES (?, ?, ?, ?)', - ) - .bind(orgId, resolvedOrgName, now, now), - db - .prepare( - 'INSERT INTO users (user_id, org_id, email, password_hash, display_name, role, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', - ) - .bind(userId, orgId, email, hash, resolvedDisplayName, 'org_owner', 'active', now, now), - ]); - - const token = await issueJWT( - { - sub: userId, - org_id: orgId, - role: 'org_owner', - iat: Math.floor(now / 1000), - exp: Math.floor(now / 1000) + DEFAULT_JWT_TTL_SECONDS, - }, - jwtSecret, - ); - - return { - user: { - user_id: userId, - org_id: orgId, - email, - display_name: resolvedDisplayName, - role: 'org_owner' as RbacRole, - status: 'active', - created_at: now, - updated_at: now, - }, - organization: { - org_id: orgId, - name: resolvedOrgName, - created_at: now, - updated_at: now, - }, - token, - }; -} - -// --------------------------------------------------------------------------- -// Login user -// --------------------------------------------------------------------------- - -export async function loginUser( - db: Database, - jwtSecret: string, - email: string, - password: string, -): Promise<{ user: UserRow; token: string }> { - const row = await db - .prepare( - 'SELECT user_id, org_id, email, password_hash, display_name, role, status, created_at, updated_at FROM users WHERE email = ?', - ) - .bind(email) - .first(); - - if (!row) { - throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.invalidCredentials' }); - } - - if (row.status !== 'active') { - throw new AppError(403, 'FORBIDDEN', { key: 'auth.accountSuspended' }); - } - - const valid = await verifyPassword(password, row.password_hash); - if (!valid) { - throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.invalidCredentials' }); - } - - const now = Date.now(); - const token = await issueJWT( - { - sub: row.user_id, - org_id: row.org_id, - role: row.role as RbacRole, - iat: Math.floor(now / 1000), - exp: Math.floor(now / 1000) + DEFAULT_JWT_TTL_SECONDS, - }, - jwtSecret, - ); - - return { - user: { - user_id: row.user_id, - org_id: row.org_id, - email: row.email, - display_name: row.display_name, - role: row.role as RbacRole, - status: row.status, - created_at: row.created_at, - updated_at: row.updated_at, - }, - token, - }; -} - // --------------------------------------------------------------------------- // Get current user profile // --------------------------------------------------------------------------- diff --git a/packages/server/src/shared/constants/index.ts b/packages/server/src/shared/constants/index.ts index 9d368c5..18d0ecf 100644 --- a/packages/server/src/shared/constants/index.ts +++ b/packages/server/src/shared/constants/index.ts @@ -7,5 +7,5 @@ export { DEFAULT_EPOCH_INTERVAL_MS, MAX_QUERY_LIMIT, DEFAULT_QUERY_LIMIT, - DEFAULT_JWT_TTL_SECONDS, + DEFAULT_SESSION_TTL_SECONDS, } from './limits.js'; diff --git a/packages/server/src/shared/constants/limits.ts b/packages/server/src/shared/constants/limits.ts index 1cbfc71..b9ae567 100644 --- a/packages/server/src/shared/constants/limits.ts +++ b/packages/server/src/shared/constants/limits.ts @@ -19,5 +19,5 @@ export const MAX_QUERY_LIMIT = 1000; /** Default number of results per query */ export const DEFAULT_QUERY_LIMIT = 100; -/** Default JWT token lifetime in seconds (24 hours) */ -export const DEFAULT_JWT_TTL_SECONDS = 86400; +/** Default session token lifetime in seconds (24 hours) */ +export const DEFAULT_SESSION_TTL_SECONDS = 86400; diff --git a/packages/server/src/shared/index.ts b/packages/server/src/shared/index.ts index 61168be..2eeb0dc 100644 --- a/packages/server/src/shared/index.ts +++ b/packages/server/src/shared/index.ts @@ -60,5 +60,5 @@ export { DEFAULT_EPOCH_INTERVAL_MS, MAX_QUERY_LIMIT, DEFAULT_QUERY_LIMIT, - DEFAULT_JWT_TTL_SECONDS, + DEFAULT_SESSION_TTL_SECONDS, } from './constants/index.js'; diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index 70eed7d..46e975c 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -30,8 +30,11 @@ export interface Env { /** Current Elydora protocol version (e.g. "1.0") */ PROTOCOL_VERSION: string; - /** HMAC secret used for JWT verification */ - JWT_SECRET: string; + /** Secret used by Better Auth for session signing and encryption */ + BETTER_AUTH_SECRET: string; + + /** Base URL for Better Auth (e.g. "http://localhost:8787") */ + BETTER_AUTH_URL: string; /** Base64url-encoded Ed25519 private key used by the server to sign receipts */ ELYDORA_SIGNING_KEY: string; @@ -50,13 +53,13 @@ export interface AppVariables { /** UUIDv7 request identifier injected by the request-id middleware */ request_id: string; - /** Organization identifier extracted from the verified JWT */ + /** Organization identifier extracted from the verified session */ org_id: string; - /** RBAC role extracted from the verified JWT */ + /** RBAC role extracted from the verified session */ role: import('./shared/index.js').RbacRole; - /** Actor identifier (sub claim) extracted from the verified JWT */ + /** Actor identifier extracted from the verified session */ actor: string; /** Detected language from Accept-Language header (set by i18n middleware) */ diff --git a/scripts/generate-keys.sh b/scripts/generate-keys.sh index a2fac58..01f127c 100755 --- a/scripts/generate-keys.sh +++ b/scripts/generate-keys.sh @@ -1,5 +1,5 @@ #!/bin/sh -# Generates an Ed25519 signing key and a random JWT secret for initial setup. +# Generates an Ed25519 signing key and a random Better Auth secret for initial setup. # Requires: openssl set -e @@ -14,12 +14,12 @@ SIGNING_KEY=$(openssl genpkey -algorithm ed25519 2>/dev/null \ | tr '+/' '-_' \ | tr -d '=\n') -# Generate 32-byte random JWT secret as hex -JWT_SECRET=$(openssl rand -hex 32) +# Generate 32-byte random Better Auth secret as hex +BETTER_AUTH_SECRET=$(openssl rand -hex 32) echo "Add these to your .env file:" echo "" echo "ELYDORA_SIGNING_KEY=${SIGNING_KEY}" -echo "JWT_SECRET=${JWT_SECRET}" +echo "BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}" echo "" echo "WARNING: Store these values securely. Do not commit them to version control." diff --git a/scripts/install.sh b/scripts/install.sh index 0363e96..19123b8 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -61,7 +61,7 @@ else # Generate secrets PG_PASS=$(openssl rand -hex 24) MINIO_PASS=$(openssl rand -hex 24) - JWT=$(openssl rand -hex 32) + BETTER_AUTH_SECRET=$(openssl rand -hex 32) SIGNING_KEY=$(openssl genpkey -algorithm ed25519 2>/dev/null \ | openssl pkcs8 -topk8 -nocrypt -outform DER 2>/dev/null \ | tail -c 32 \ @@ -71,7 +71,7 @@ else [ -n "$PG_PASS" ] || fail "Failed to generate Postgres password." [ -n "$MINIO_PASS" ] || fail "Failed to generate MinIO password." - [ -n "$JWT" ] || fail "Failed to generate JWT secret." + [ -n "$BETTER_AUTH_SECRET" ] || fail "Failed to generate Better Auth secret." [ -n "$SIGNING_KEY" ] || fail "Failed to generate Ed25519 signing key." # Replace placeholders @@ -79,7 +79,7 @@ else sedi "s|elydora:GENERATE_ME@postgres|elydora:${PG_PASS}@postgres|" .env sedi "s|MINIO_ROOT_PASSWORD=GENERATE_ME|MINIO_ROOT_PASSWORD=${MINIO_PASS}|" .env sedi "s|MINIO_SECRET_KEY=GENERATE_ME|MINIO_SECRET_KEY=${MINIO_PASS}|" .env - sedi "s|JWT_SECRET=GENERATE_ME|JWT_SECRET=${JWT}|" .env + sedi "s|BETTER_AUTH_SECRET=GENERATE_ME|BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}|" .env sedi "s|ELYDORA_SIGNING_KEY=GENERATE_ME|ELYDORA_SIGNING_KEY=${SIGNING_KEY}|" .env chmod 600 .env diff --git a/sdks/go/README.md b/sdks/go/README.md index 8c6aff8..dfdafd9 100644 --- a/sdks/go/README.md +++ b/sdks/go/README.md @@ -23,18 +23,15 @@ import ( ) func main() { - // Authenticate - auth, err := elydora.Login("https://api.elydora.com", "user@example.com", "password") - if err != nil { - log.Fatal(err) - } - - // Create client + // Initialize the client with your API token. + // Obtain an API token by signing in via the Elydora console or: + // POST /api/auth/sign-in/email -> get session token + // POST /v1/auth/token -> exchange for long-lived API token client, err := elydora.NewClient(&elydora.Config{ - OrgID: auth.User.OrgID, + OrgID: "org-123", AgentID: "my-agent-id", PrivateKey: "", - Token: auth.Token, + Token: "your-api-token", }) if err != nil { log.Fatal(err) @@ -108,36 +105,35 @@ client, err := elydora.NewClient(&elydora.Config{ BaseURL: "https://...", // API base URL (default: https://api.elydora.com) TTLMs: 30000, // Operation TTL in ms (default: 30000) MaxRetries: 3, // Max retries on transient failures (default: 3) - Token: "", // JWT token for authenticated requests + Token: "", // API token for authenticated requests }) ``` ### Client Methods ```go -// Update the JWT token at runtime -client.SetToken("new-jwt-token") +// Update the API token at runtime +client.SetToken("new-api-token") ``` ### Authentication +Authentication uses Better Auth. Register and sign in via the Elydora console or the Better Auth endpoints, then issue a long-lived API token for SDK use: + ```go -// Register a new user and organization -reg, err := elydora.Register(baseURL, email, password, - elydora.WithDisplayName("Alice"), - elydora.WithOrgName("Acme Corp"), -) - -// Login and receive a JWT -auth, err := elydora.Login(baseURL, email, password) - -// Get current authenticated user profile -me, err := client.GetMe() - -// Issue a new API token (with optional TTL) +// Sign up (Better Auth) — use the console or call directly: +// POST /api/auth/sign-up/email { email, password, name } +// +// Sign in (Better Auth) — get a session token: +// POST /api/auth/sign-in/email { email, password } +// +// Issue a long-lived API token from an active session: tokenResp, err := client.IssueApiToken(&elydora.IssueApiTokenRequest{ TTLSeconds: &ttlSeconds, }) + +// Update the token on an existing client instance at runtime +client.SetToken("new-api-token") ``` ### Operations diff --git a/sdks/go/auth.go b/sdks/go/auth.go index c48f30f..a332d44 100644 --- a/sdks/go/auth.go +++ b/sdks/go/auth.go @@ -28,6 +28,8 @@ func WithOrgName(name string) RegisterOption { } // Register creates a new user and organization. +// +// Deprecated: Use Better Auth endpoints directly. See docs. func Register(baseURL, email, password string, opts ...RegisterOption) (*AuthRegisterResponse, error) { if baseURL == "" { baseURL = defaultBaseURL @@ -49,7 +51,9 @@ func Register(baseURL, email, password string, opts ...RegisterOption) (*AuthReg return &result, nil } -// Login authenticates a user and returns a JWT token. +// Login authenticates a user and returns a session token. +// +// Deprecated: Use Better Auth endpoints directly. See docs. func Login(baseURL, email, password string) (*AuthLoginResponse, error) { if baseURL == "" { baseURL = defaultBaseURL diff --git a/sdks/go/client.go b/sdks/go/client.go index e51cb15..da15b37 100644 --- a/sdks/go/client.go +++ b/sdks/go/client.go @@ -24,7 +24,7 @@ type Config struct { BaseURL string // defaults to "https://api.elydora.com" TTLMs int // defaults to 30000 MaxRetries int // defaults to 3 - Token string // JWT token for authenticated requests + Token string // API token for authenticated requests } // Client is the Elydora SDK client. @@ -172,7 +172,7 @@ func (c *Client) doPost(path string, body interface{}, result interface{}) error return c.doRequest(http.MethodPost, path, body, result) } -// SetToken sets the JWT token used for authenticated API requests. +// SetToken sets the API token used for authenticated API requests. func (c *Client) SetToken(token string) { c.token = token } diff --git a/sdks/go/cmd/elydora/main.go b/sdks/go/cmd/elydora/main.go index 849e9f3..87dc1c6 100644 --- a/sdks/go/cmd/elydora/main.go +++ b/sdks/go/cmd/elydora/main.go @@ -69,7 +69,7 @@ func cmdInstall(args []string) { agentID := fs.String("agent-id", "", "Agent ID (required)") privateKey := fs.String("private-key", "", "Base64url-encoded Ed25519 private key seed (required)") kid := fs.String("kid", "", "Key ID (defaults to -key-1)") - token := fs.String("token", "", "JWT token for authenticated requests (optional)") + token := fs.String("token", "", "API token for authenticated requests (optional)") baseURL := fs.String("base-url", "https://api.elydora.com", "Elydora API base URL") fs.Parse(args) diff --git a/sdks/node/README.md b/sdks/node/README.md index 5b6adc9..75d93bf 100644 --- a/sdks/node/README.md +++ b/sdks/node/README.md @@ -15,17 +15,17 @@ Requires Node.js 18+ (uses built-in `crypto` module with Ed25519 support). ```typescript import { ElydoraClient } from '@elydora/sdk'; -// Authenticate -const auth = await ElydoraClient.login('https://api.elydora.com', 'user@example.com', 'password'); - -// Create client +// Initialize the client with your API token. +// Obtain an API token by signing in via the Elydora console or: +// POST /api/auth/sign-in/email → get session token +// POST /v1/auth/token → exchange for long-lived API token const client = new ElydoraClient({ - orgId: auth.user.org_id, + orgId: 'org-123', agentId: 'my-agent-id', privateKey: '', baseUrl: 'https://api.elydora.com', + token: 'your-api-token', }); -client.setToken(auth.token); // Create and submit an operation const eor = client.createOperation({ @@ -96,7 +96,7 @@ const client = new ElydoraClient({ // Register a new user and organization const reg = await ElydoraClient.register(baseUrl, email, password, displayName?, orgName?); -// Login and receive a JWT +// Login and receive a session token const auth = await ElydoraClient.login(baseUrl, email, password); // Set token on client diff --git a/sdks/node/package-lock.json b/sdks/node/package-lock.json index c73862a..ff96116 100644 --- a/sdks/node/package-lock.json +++ b/sdks/node/package-lock.json @@ -1,13 +1,16 @@ { "name": "@elydora/sdk", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@elydora/sdk", - "version": "1.0.0", + "version": "1.1.0", "license": "MIT", + "bin": { + "elydora": "dist/cli.js" + }, "devDependencies": { "@types/node": "^22.0.0", "typescript": "^5.7.0" diff --git a/sdks/node/src/cli.ts b/sdks/node/src/cli.ts index b4e42d9..25719b1 100644 --- a/sdks/node/src/cli.ts +++ b/sdks/node/src/cli.ts @@ -41,7 +41,7 @@ function printUsage(): void { console.log(`Elydora CLI — Tamper-evident audit for AI coding agents Usage: - elydora install --agent --org_id --agent_id --private_key --kid [--token ] [--base_url ] + elydora install --agent --org_id --agent_id --private_key --kid [--token ] [--base_url ] elydora uninstall --agent [--agent_id ] elydora status elydora agents diff --git a/sdks/node/src/client.ts b/sdks/node/src/client.ts index 205c875..4ea1e2d 100644 --- a/sdks/node/src/client.ts +++ b/sdks/node/src/client.ts @@ -65,7 +65,7 @@ export class ElydoraClient { } /** - * Set the JWT token used for authenticated API calls. + * Set the session token used for authenticated API calls. */ setToken(token: string): void { this.token = token; @@ -89,6 +89,9 @@ export class ElydoraClient { // Auth (static methods — no instance needed) // ------------------------------------------------------------------------- + /** + * @deprecated Use Better Auth endpoints directly. See docs. + */ static async register( baseUrl: string, email: string, @@ -110,6 +113,9 @@ export class ElydoraClient { return handleResponse(res); } + /** + * @deprecated Use Better Auth endpoints directly. See docs. + */ static async login( baseUrl: string, email: string, diff --git a/sdks/python/README.md b/sdks/python/README.md index 5e91110..a1a9f50 100644 --- a/sdks/python/README.md +++ b/sdks/python/README.md @@ -15,16 +15,16 @@ Requires Python 3.9+. ```python from elydora import ElydoraClient -# Authenticate -auth = ElydoraClient.login("https://api.elydora.com", "user@example.com", "password") - -# Create client +# Initialize the client with your API token. +# Obtain an API token by signing in via the Elydora console or: +# POST /api/auth/sign-in/email -> get session token +# POST /v1/auth/token -> exchange for long-lived API token client = ElydoraClient( - org_id=auth["user"]["org_id"], + org_id="org-123", agent_id="my-agent-id", private_key="", base_url="https://api.elydora.com", - token=auth["token"], + token="your-api-token", ) # Create and submit an operation @@ -48,7 +48,7 @@ async def main(): org_id="org-123", agent_id="agent-456", private_key="", - token="", + token="", ) eor = client.create_operation( @@ -107,24 +107,26 @@ client = ElydoraClient( base_url="https://...", # API base URL (default: https://api.elydora.com) ttl_ms=30000, # Operation TTL in ms (default: 30000) max_retries=3, # Max retries on transient failures (default: 3) - token="", # Optional JWT bearer token + token="", # Optional API token ) ``` ### Authentication +Authentication uses Better Auth. Register and sign in via the Elydora console or the Better Auth endpoints, then issue a long-lived API token for SDK use: + ```python -# Register a new user and organization -reg = ElydoraClient.register(base_url, email, password, display_name=None, org_name=None) - -# Login and receive a JWT -auth = ElydoraClient.login(base_url, email, password) - -# Get current authenticated user profile -me = client.get_me() - -# Issue a new API token (with optional TTL in seconds) +# Sign up (Better Auth) — use the console or call directly: +# POST /api/auth/sign-up/email { email, password, name } +# +# Sign in (Better Auth) — get a session token: +# POST /api/auth/sign-in/email { email, password } +# +# Issue a long-lived API token from an active session: token_resp = client.issue_token(ttl_seconds=3600) + +# Update the token on an existing client instance +client.set_token("new-api-token") ``` ### Operations diff --git a/sdks/python/elydora/async_client.py b/sdks/python/elydora/async_client.py index ff741b4..492759e 100644 --- a/sdks/python/elydora/async_client.py +++ b/sdks/python/elydora/async_client.py @@ -4,6 +4,7 @@ from __future__ import annotations import time import asyncio +import warnings from typing import Any, Dict, List, Optional, Union import aiohttp @@ -50,7 +51,7 @@ class AsyncElydoraClient: base_url: API base URL. ttl_ms: Time-to-live for operations in milliseconds. max_retries: Maximum number of retries on transient failures. - token: Optional JWT bearer token for authenticated endpoints. + token: Optional bearer token for authenticated endpoints. """ def __init__( @@ -171,7 +172,16 @@ class AsyncElydoraClient: display_name: Optional[str] = None, org_name: Optional[str] = None, ) -> AuthRegisterResponse: - """Register a new user and organization.""" + """Register a new user and organization. + + .. deprecated:: + Use Better Auth endpoints directly. See docs. + """ + warnings.warn( + "AsyncElydoraClient.register() is deprecated. Use Better Auth endpoints directly. See docs.", + DeprecationWarning, + stacklevel=2, + ) url = f"{base_url.rstrip('/')}/v1/auth/register" body: Dict[str, Any] = {"email": email, "password": password} if display_name is not None: @@ -186,7 +196,16 @@ class AsyncElydoraClient: @staticmethod async def login(base_url: str, email: str, password: str) -> AuthLoginResponse: - """Authenticate and receive a JWT.""" + """Authenticate and receive a session token. + + .. deprecated:: + Use Better Auth endpoints directly. See docs. + """ + warnings.warn( + "AsyncElydoraClient.login() is deprecated. Use Better Auth endpoints directly. See docs.", + DeprecationWarning, + stacklevel=2, + ) url = f"{base_url.rstrip('/')}/v1/auth/login" body = {"email": email, "password": password} async with aiohttp.ClientSession() as session: diff --git a/sdks/python/elydora/cli.py b/sdks/python/elydora/cli.py index b5ced21..522c29d 100644 --- a/sdks/python/elydora/cli.py +++ b/sdks/python/elydora/cli.py @@ -172,7 +172,7 @@ def build_parser() -> argparse.ArgumentParser: install_parser.add_argument("--agent_id", required=True, help="Agent ID") install_parser.add_argument("--private_key", required=True, help="Base64url-encoded Ed25519 private key seed") install_parser.add_argument("--kid", required=True, help="Key ID") - install_parser.add_argument("--token", default="", help="JWT bearer token (optional)") + install_parser.add_argument("--token", default="", help="API bearer token (optional)") install_parser.add_argument("--base_url", default="https://api.elydora.com", help="API base URL") # uninstall diff --git a/sdks/python/elydora/client.py b/sdks/python/elydora/client.py index 292544f..99f9fdb 100644 --- a/sdks/python/elydora/client.py +++ b/sdks/python/elydora/client.py @@ -3,6 +3,7 @@ from __future__ import annotations import time +import warnings from typing import Any, Dict, List, Optional, Union import requests @@ -49,7 +50,7 @@ class ElydoraClient: base_url: API base URL. ttl_ms: Time-to-live for operations in milliseconds. max_retries: Maximum number of retries on transient failures. - token: Optional JWT bearer token for authenticated endpoints. + token: Optional bearer token for authenticated endpoints. """ def __init__( @@ -168,7 +169,16 @@ class ElydoraClient: display_name: Optional[str] = None, org_name: Optional[str] = None, ) -> AuthRegisterResponse: - """Register a new user and organization.""" + """Register a new user and organization. + + .. deprecated:: + Use Better Auth endpoints directly. See docs. + """ + warnings.warn( + "ElydoraClient.register() is deprecated. Use Better Auth endpoints directly. See docs.", + DeprecationWarning, + stacklevel=2, + ) url = f"{base_url.rstrip('/')}/v1/auth/register" body: Dict[str, Any] = {"email": email, "password": password} if display_name is not None: @@ -180,7 +190,16 @@ class ElydoraClient: @staticmethod def login(base_url: str, email: str, password: str) -> AuthLoginResponse: - """Authenticate and receive a JWT.""" + """Authenticate and receive a session token. + + .. deprecated:: + Use Better Auth endpoints directly. See docs. + """ + warnings.warn( + "ElydoraClient.login() is deprecated. Use Better Auth endpoints directly. See docs.", + DeprecationWarning, + stacklevel=2, + ) url = f"{base_url.rstrip('/')}/v1/auth/login" body = {"email": email, "password": password} resp = requests.post(url, json=body, headers={"Content-Type": "application/json"}, timeout=30)