Products
MySSO
A home-built identity provider, wired into my own applications
- Role
- Design and development
- Context
- Personal project in production
- Stack
- Express, TypeScript, Next.js 16, PostgreSQL, Prisma, OIDC · RS256

Overview
Custom Single Sign-On implementation with OpenID Connect and OAuth2 support, PKCE, user consent management and RSA JWT signing. It acts as the OpenID Connect provider for my applications: Quarto in production, MagicCards in dev and staging.
Highlights
- 01OpenID Connect and OAuth2 with PKCE
- 02Consent screen listing the requested permissions
- 03RS256-signed JWTs, public key on /jwks.json, rotating refresh tokens
- 04Dashboard: active sessions, authorized applications and revocation
- 05Real clients: Quarto in production, MagicCards in dev and staging
Architecture
Under the hood
A home-built OAuth 2.0 / OpenID Connect identity provider. A single Express backend issues and verifies every token, signed RS256 with an RSA key. A Next.js frontend carries the screens users see: sign-in, consent, dashboard and admin console. PostgreSQL, through Prisma, is the only source of truth: sessions, authorization codes, consents and the audit log all live there.
- steps in the code + PKCE flow
- 16steps in the code + PKCE flow
- API routes across four routers
- 44API routes across four routers
- PostgreSQL tables, all around User
- 11PostgreSQL tables, all around User
- Next.js pages, 6 of them for admin
- 16Next.js pages, 6 of them for admin
- access token lifetime
- 15minaccess token lifetime
- code lifetime, single use
- 60scode lifetime, single use
Who talks to whom
Clients
Applis clientes OIDC
Quarto in production, MagicCards in dev · client_id, PKCE, declared redirect_uri
Navigateur
Follows /authorize redirects · HttpOnly cookies
Frontend MySSO
Next.js 16 · React 19 · sign-in, consent, dashboard, admin
GET /authorize · POST /token · GET /userinfo · fetch with Bearer or cookie
Express · middleware
helmet
CSP, HSTS, X-Frame-Options DENY
CORS
ALLOWED_ORIGINS allowlist
Rate limit
120 req/min, then per-route limits
Parsers
Cookies, JSON body capped at 10 kB
authMiddleware added per route, requireScope('admin') for admin
Express · routers
authRoutes
/auth/* · /authorize · /token · /userinfo · /jwks.json · discovery
userRoutes
Profile, password, sessions, consents
adminRoutes
Admin scope · accounts, apps, scopes, audit, settings
Outils
/health with DB latency · Swagger on /api-docs
Prisma · JwtService · SecurityLogger · EmailService
Data and services
PostgreSQL
Supabase in production · Docker locally · Prisma 6
Clés RSA 2048
Public key served on /jwks.json
Journal d'audit
AuditLog table, read by the admin console
SMTP
nodemailer, console fallback
Design principles
01
A single token issuer
Only the Express backend signs JWTs. The private key never leaves it; the public key is published on /jwks.json.
02
A frontend without auth logic
Next.js pages call the API. Password, consent, code issuance: every decision is made in Express.
03
One database, nothing in memory
Codes, refresh tokens, sessions and consents live in the database. The server is stateless apart from rate-limit counters.
04
Declared client apps
Each client has its own redirectUris and allowed scopes. An unlisted redirect is refused before sign-in even starts.
05
Admins are a scope
An isAdmin user gets the admin scope in their access token; requireScope('admin') guards all of /admin/*.
06
Everything is logged
Sign-ins, failures, token issuance, revocations and admin actions are written to the AuditLog table.
OpenID Connect flow · authorization code + PKCE
- 01App cliente → NavigateurRedirects to /authorize
- 02Navigateur → BackendGET /authorize · client_id, state, nonce, code_challenge
- 03Backend → FrontendNo session → /login?returnTo
- 04Navigateur → FrontendEmail and password
- 05Frontend → BackendPOST /auth/login
- 06Backend → PostgreSQLargon2 check · Session created
- 07Backend → FrontendHttpOnly cookies + tokens
- 08Navigateur → BackendBack to /authorize with the cookie
- 09Backend → FrontendNo consent → /consent
- 10Navigateur → Frontend“Allow”
- 11Frontend → BackendGET /authorize?approved=true
- 12Backend → PostgreSQLUserConsent + AuthCode
- 13Backend → App clienteRedirect to redirect_uri?code&state
- 14App cliente → BackendPOST /token · code + code_verifier
- 15Backend → App clienteaccess_token · refresh_token · id_token
- 16App cliente → BackendGET /userinfo with Bearer
A signed-in user skips steps 3 to 7, an existing consent skips 9 to 11. On exchange, the backend checks the code exists, is unused and unexpired and that the redirect_uri matches, then verifies PKCE. The id_token is only issued if the openid scope was consented.
Sessions and revocation
Every refresh rotates the refresh token on the same Session. The admin scope is recomputed on each refresh, and a disabled account is blocked immediately because the middleware rereads its state from the database. Each sign-in creates a Session with IP and user agent, which users list and revoke from their dashboard. Changing a password closes every session.
Tokens and lifetimes
| Token | Format | Lifetime | Server side |
|---|---|---|---|
| Access token | JWT RS256 · sub, email, scope | 15 min | Verified by signature and active account |
| Refresh token | JWT RS256 · jti | 90 d | RefreshToken table, rotated on every use |
| ID token | JWT RS256 · aud, nonce | 1 h | Only issued with the openid scope |
| Authorization code | UUID v4 | 60 s | AuthCode table, deleted on use |
| Reset link | Random token | 1 h | Single use |
| Verification link | Random token | 24 h | EmailVerificationToken table |
Scopes
| Scope | Grants |
|---|---|
| openid | id_token issuance |
| profile | Profile info in /userinfo |
| Email address | |
| username | preferred_username |
| admin | /admin/* routes · never requested by a client, added at sign-in when isAdmin |
Data model · 11 tables
| Table | Contents |
|---|---|
| User | Email, username, hash, isAdmin, isDisabled, emailVerified |
| Session · RefreshToken | Sessions with IP and user agent, linked refresh tokens |
| AuthCode | 60-second codes with nonce and code_challenge |
| UserConsent | Granted scopes, one per user and client |
| Client | Public clientId, argon2id secret, redirectUris, allowed scopes |
| PasswordResetToken · EmailVerificationToken | Emailed links, cascade delete |
| Scope · AuditLog · SystemSetting | Scope catalogue, audit log, admin settings |
AuditLog keeps a free userId, with no foreign key, so it survives account deletion. Thirteen Prisma migrations, November 2025 to April 2026.
API · four routers
| Router | Routes | Protection |
|---|---|---|
| Account | /auth/register · login · refresh · logout · reset · verify | Per-IP limits, refresh token |
| OAuth 2.0 / OIDC | /authorize · /token · /userinfo · /jwks.json · /.well-known | Cookie or Bearer, public for discovery |
| User | /user/consents · sessions · profile · password · account | authMiddleware |
| Administration | /admin/users · clients · scopes · audit-logs · settings | Admin scope |
Security, layer by layer
| Layer | Mechanism |
|---|---|
| HTTP headers | helmet: CSP 'self', one-year HSTS + preload, X-Frame-Options DENY, nosniff |
| Passwords | argon2id, 64 MB, 3 iterations; same message for unknown email or wrong password |
| Client secrets | 32 random bytes, stored as argon2id, shown once |
| Tokens | RS256 only, issuer checked, single-use refresh |
| Redirects | redirect_uri matched against the client's declared list |
| Cookies | HttpOnly, secure in production, SameSite=strict for refresh |
| Audit | Sign-ins, failures, tokens, revocations and admin actions logged |
CI/CD and environments
PR
GitHub Actions CI
develop
Integration, CI
main
Production
CI runs on Node 18 and 20 with a Postgres 15 service: Prisma migrations, backend TypeScript build, frontend Next.js build, then the server starts and /health is checked.
Full stack
| Tech | Version | Role |
|---|---|---|
| Node.js · TypeScript | 18–20 · 5.3 | Runtime and language |
| Express | 4.18 | HTTP server and routing |
| jsonwebtoken | 9 | RS256 signing and verification |
| argon2 | 0.31 | Password and secret hashing |
| helmet · cors · cookie-parser | 8 · 2.8 · 1.4 | Headers, CORS, cookies |
| express-rate-limit | 8.3 | Rate limiting |
| nodemailer | 8 | Verification and reset emails |
| swagger-jsdoc · swagger-ui-express | 6 · 5 | API documentation |
| Tech | Version | Role |
|---|---|---|
| Prisma | 6.19 | ORM, migrations |
| PostgreSQL | 15 · 16 | CI and local; Supabase in production |
| Tech | Version | Role |
|---|---|---|
| Next.js (App Router) | 16.2 | Sign-in, consent, dashboard, admin |
| React | 19.2 | UI |
| Tailwind CSS | 4 | Styling, light and dark theme |
| Tech | Version | Role |
|---|---|---|
| Vercel | — | my-sso.louis-bertrand.fr frontend |
| Render | — | Backend, per the repository |
| Supabase | — | Managed PostgreSQL, pooler |
| GitHub Actions · Docker Compose | — | CI · local Postgres |
Read from the code, main branch, v3.4.0, 20 October 2026
Screens
Hover or use the keyboard to enlarge a screen




Next project OCR
Sudoku OCR →