Self-hosted OAuth 2.1 / OIDC identity provider built on BetterAuth v1.5.6.
Features: email/password auth, MFA (TOTP + YubiKey FIDO2), multi-app RBAC, subscription plans with feature flags, consumption tracking, organization management (multi-tenant ready), and an embedded Vue 3 admin SPA.
| Layer | Tech |
|---|---|
| Runtime | Node.js 22, TypeScript (ESM) |
| Web framework | Fastify 5 |
| Auth engine | BetterAuth + @better-auth/oauth-provider |
| ORM | Drizzle ORM + postgres driver |
| Database | PostgreSQL 17 |
| Frontend | Vue 3 + Vite + Tailwind CSS v4 |
| Tests | Vitest + Supertest |
| Container | Docker (multi-stage build) |
auth-service/
├── src/
│ ├── index.ts # Fastify server entry point
│ ├── auth.ts # BetterAuth configuration & plugins
│ ├── config.ts # Zod-validated env config
│ ├── errors.ts # ApiError class + error codes
│ ├── bootstrap.ts # Superadmin auto-creation at startup
│ ├── migrate.ts # Programmatic Drizzle migrations
│ ├── db/
│ │ ├── index.ts # Drizzle + postgres connection
│ │ └── schema.ts # All custom table definitions
│ ├── services/
│ │ └── claims.ts # OIDC custom claims builder (RBAC + features)
│ └── routes/
│ ├── health.ts # GET /health
│ ├── consumption.ts # Consumption tracking API
│ └── admin/
│ ├── applications.ts
│ ├── organizations.ts # Organization CRUD + member & invitation management
│ ├── roles.ts
│ ├── plans.ts
│ └── users.ts
├── frontend/ # Vue 3 SPA (built to frontend-dist/)
│ └── src/
│ ├── views/ # Login, register, profile, consent, admin pages
│ ├── stores/ # Pinia auth store
│ ├── router/ # Vue Router with auth guards
│ └── locales/ # i18n (en, fr)
├── drizzle/ # Generated SQL migrations
├── Dockerfile # Multi-stage build
├── docker-compose.yml # Production
├── docker-compose.dev.yml # Dev (postgres only + hot-reload server)
└── .env.example
- Node.js 22+
- pnpm 9+
- Docker + Docker Compose
cp .env.example .env
# Edit .env — at minimum set BETTER_AUTH_SECRET and BETTER_AUTH_URL
openssl rand -base64 32 # use this as BETTER_AUTH_SECRETdocker compose -f docker-compose.dev.yml up -d postgresThe postgres container is exposed on port 5433 (to avoid conflicts with a local postgres on 5432).
pnpm install
pnpm db:pushdb:push syncs the Drizzle schema directly to the DB without generating migration files — ideal for development.
pnpm dev # tsx watch — hot-reload on file changesServer: http://localhost:3001
cd frontend
pnpm install
pnpm dev # Vite dev server with HMRFrontend: http://localhost:5173 (proxied to the backend at 3001)
For quick iteration you can also just use the backend on port 3001 — the Vue SPA is served from
frontend-dist/when built.
| Method | Path | Description |
|---|---|---|
GET |
/health |
Health check |
ALL |
/api/auth/* |
BetterAuth handler (login, register, OAuth2, OIDC, MFA…) |
GET/POST |
/api/admin/applications |
List / create applications |
GET/PATCH/DELETE |
/api/admin/applications/:id |
Application CRUD |
POST |
/api/admin/applications/:id/rotate-secret |
Rotate client secret |
GET/POST/DELETE |
/api/admin/applications/:id/roles |
Per-app role management |
GET/POST/DELETE |
/api/admin/applications/:id/permissions |
Per-app permission management |
GET/POST/DELETE |
/api/admin/applications/:id/plans |
Subscription plan management |
GET/PATCH |
/api/admin/users |
User list / update |
POST |
/api/admin/users/:id/disable |
Ban user |
POST |
/api/admin/users/:id/enable |
Unban user |
GET/POST |
/api/admin/organizations |
List / create organizations |
GET/DELETE |
/api/admin/organizations/:id |
Get / delete an organization |
GET/POST |
/api/admin/organizations/:id/members |
List members / add a member directly |
DELETE |
/api/admin/organizations/:id/members/:uid |
Remove a member |
PATCH |
/api/admin/organizations/:id/members/:mid/role |
Update a member's role |
GET/POST |
/api/admin/organizations/:id/invitations |
List invitations / send an invitation by email |
DELETE |
/api/admin/organizations/:id/invitations/:iid |
Cancel a pending invitation |
POST |
/api/consumption |
Record consumption (client_credentials token) |
GET |
/api/consumption/:userId/:appId |
Get aggregates for user+app |
DELETE |
/api/consumption/:userId/:appId/:key |
Reset a counter |
| Path | Description |
|---|---|
/api/auth/oauth2/authorize |
Authorization endpoint |
/api/auth/oauth2/token |
Token endpoint |
/api/auth/oauth2/userinfo |
UserInfo endpoint |
/api/auth/.well-known/openid-configuration |
Discovery document |
/api/auth/jwks |
JSON Web Key Set |
auth-service includes first-class support for organizations (companies / tenants) powered by the BetterAuth organization() plugin.
The organization model is designed for a two-tier distribution chain:
| Tier | Actor | Role |
|---|---|---|
| 1 | Reseller / distributor | Creates and owns organizations representing their end-clients |
| 2 | Integrator | Is a member of one or more organizations; manages IT resources scoped to those organizations |
Each organization has a unique slug (used as its stable identifier) and optional logo + metadata. Members are assigned one of three roles: owner, admin, or member.
When a client requests the org scope during an OAuth2 authorization flow, the access token issued by auth-service contains an org_id claim set to the user's active organization ID (session.activeOrganizationId). Backend services can use this claim to:
- Scope database queries to the organization's data
- Enforce CASL (
@lagarde-cyber/acl) abilities with{ org_id }conditions - Isolate resources between tenants
Only users with role admin or superadmin can create organizations. Member management (add, remove, change role) and invitation flows (invite by email, cancel) are also admin-only operations accessible via the /api/admin/organizations/* routes.
Regular users can belong to multiple organizations as members but cannot create them.
# Generate migration files from schema changes
pnpm db:generate
# Apply migrations (production-style)
pnpm db:migrate
# Push schema directly (dev only — no migration files)
pnpm db:pushIn production (Docker), migrations run automatically at container startup via runMigrations() in src/migrate.ts.
# Fill in production values in .env (never commit this file)
docker compose build
docker compose up -dThe service is exposed on the port defined by PORT (default 3001).
Add a reverse proxy (Traefik, nginx, Caddy) in front of it for TLS termination.
| Variable | Required | Description |
|---|---|---|
PORT |
no | Server port (default: 3001) |
HOST |
no | Bind address (default: 0.0.0.0) |
BETTER_AUTH_SECRET |
yes | 32-byte random secret — openssl rand -base64 32 |
BETTER_AUTH_URL |
yes | Public base URL, e.g. https://auth.example.com |
DATABASE_URL |
yes | Postgres connection string |
POSTGRES_USER |
yes | Postgres user (also used by compose) |
POSTGRES_PASSWORD |
yes | Postgres password |
POSTGRES_DB |
yes | Database name |
POSTGRES_PORT |
no | Host port for postgres (default: 5433 in dev) |
ADMIN_EMAIL |
no | Superadmin email — created at first boot |
ADMIN_PASSWORD |
no | Superadmin password |
CORS_ORIGINS |
no | Comma-separated allowed origins |
APP_NAME |
no | Display name in UI + TOTP issuer (default: CIRCLE Auth) |
APP_LOGO_URL |
no | Logo URL for UI + favicon (falls back to default) |
SMTP_HOST |
no | SMTP server (email features disabled if empty) |
SMTP_PORT |
no | SMTP port (default: 587) |
SMTP_USER |
no | SMTP username |
SMTP_PASS |
no | SMTP password |
SMTP_FROM |
no | From address for outgoing emails |
NODE_ENV |
no | development or production |
pnpm test # Run all tests once
pnpm test:watch # Watch modeTests use Vitest with a node environment. Integration tests requiring a live DB are separated in vitest.integration.config.ts.