juggernauth
OAuth2 Authorization Server implementing the Authorization Code + PKCE flow with ES256 JWT tokens. Acts as a central auth authority for first-party services — users register and log in here, and downstream services validate JWTs locally via the JWKS endpoint.
How it works
OAuth2 authorization flow:
- User visits a service that needs authentication
- Service redirects to
GET /oauth/authorize with a PKCE challenge
- User logs in (or is already logged in via session)
- juggernauth redirects back with an authorization code
- Service exchanges the code for a signed JWT at
POST /oauth/token
- Service validates the JWT locally using the public key from
GET /.well-known/jwks.json
Password reset flow:
- User submits their email at
POST /auth/forgot-password
- juggernauth emails a reset link (requires
RESEND_API_KEY)
- User clicks the link and submits a new password at
POST /auth/reset-password
- All existing sessions are invalidated
Running locally
Prerequisites
- Go 1.25+
- templ CLI
- PostgreSQL
Setup
1. Generate a signing key
openssl ecparam -name prime256v1 -genkey -noout | openssl ec -traditional
Copy the output and escape newlines for use in .env:
openssl ecparam -name prime256v1 -genkey -noout \
| openssl ec -traditional \
| awk '{printf "%s\\n", $0}' | sed 's/\\n$/\n/'
2. Create the database schema
psql -d your_database -f schema.sql
3. Create a .env file
APP_ENV=development
DB_CONNECTION_STRING=postgres://user:password@localhost:5432/juggernauth?sslmode=disable
BASE_URL=http://localhost:8080
JWT_PRIVATE_KEY_PEM=-----BEGIN EC PRIVATE KEY-----\nMHQCAQEE...\n-----END EC PRIVATE KEY-----
ALLOWED_REDIRECT_URIS=http://localhost:3000/auth/callback
# Optional: email sending via Resend (password reset emails)
RESEND_API_KEY=re_...
[email protected]
The PEM key must be on a single line with \n escaped newlines.
Without RESEND_API_KEY, password reset emails are logged to stdout instead of sent.
4. Run
make run
This starts a live-reload server with templ watching for template changes. The app is available at http://localhost:8080.
Testing
# Unit tests only (no database required)
make test
# Integration tests (spins up a throwaway Postgres container)
make test-integration
# Coverage report — unit tests only
make coverage
# Coverage report — unit + integration tests (recommended)
make coverage-full
make coverage-full and make test-integration require Docker. The integration tests are skipped automatically when TEST_DB_CONNECTION_STRING is not set, so make test always works without a database.
Deployment
Build the binary
make build
# outputs dist/juggernauth
Docker
docker build -t juggernauth .
docker run -p 6553:6553 \
-e DB_CONNECTION_STRING="postgres://..." \
-e JWT_PRIVATE_KEY_PEM="$(cat ec-private.pem | awk '{printf "%s\\n", $0}')" \
-e BASE_URL="https://auth.yourdomain.com" \
-e ALLOWED_REDIRECT_URIS="https://service-a.yourdomain.com/callback,https://service-b.yourdomain.com/callback" \
juggernauth
Environment variables
| Variable |
Required |
Default |
Description |
DB_CONNECTION_STRING |
yes |
— |
PostgreSQL DSN |
JWT_PRIVATE_KEY_PEM |
yes |
— |
PEM-encoded ECDSA P-256 private key (single line, \n-escaped) |
ALLOWED_REDIRECT_URIS |
no |
— |
Comma-separated list of permitted OAuth redirect URIs |
BASE_URL |
no |
http://localhost:8080 |
Public URL of this service (used in JWT iss claim) |
SERVER_PORT |
no |
8080 |
Port to listen on (6553 in Docker) |
SESSION_TIMEOUT |
no |
24h |
Browser session lifetime (e.g. 12h, 168h) |
APP_ENV |
no |
development |
Set to production to enable secure cookies and other hardening |
RESEND_API_KEY |
no |
— |
Resend API key for sending password reset emails; if unset, emails are logged to stdout |
EMAIL_FROM |
no |
[email protected] |
Sender address for outgoing emails |
DB_MAX_CONNS |
no |
10 |
Maximum number of database connections in the pool |
DB_MIN_CONNS |
no |
2 |
Minimum number of idle database connections in the pool |
DB_CONNECT_TIMEOUT |
no |
5s |
Timeout for establishing new database connections |
Integrating a service
To protect a service with juggernauth:
- Add its callback URL to
ALLOWED_REDIRECT_URIS
- Redirect unauthenticated users to:
GET https://auth.yourdomain.com/oauth/authorize
?redirect_uri=https://service-a.yourdomain.com/callback
&response_type=code
&code_challenge=<base64url(SHA256(verifier))>
&code_challenge_method=S256
&state=<random>
- On callback, exchange the code:
POST https://auth.yourdomain.com/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=<code>
&redirect_uri=https://service-a.yourdomain.com/callback
&code_verifier=<verifier>
- Validate the returned JWT using the public key from
GET /.well-known/jwks.json
JWT claims: iss (base URL), sub (user UUID), email, aud (redirect URI host), exp (15 min), jti.