OAuth 2.0 / OpenID Connect identity provider built with Go and PostgreSQL.
- OAuth 2.0 Authorization Code flow with PKCE
- OpenID Connect UserInfo endpoint
- User registration and authentication
- Two-factor authentication (TOTP) — optional MFA via authenticator apps
- Client management via CLI
- Session management with secure cookies
- Token refresh and revocation
- Client Integration Guide — How to integrate your application with this IdP, including user storage, roles, and permissions
This project requires a few CLI tools:
# swaggo/swag for generating openapi docs
go install github.com/swaggo/swag/cmd/swag@latest
# sqlc-dev/sqlc for generating type-safe database code
go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.30.0 # or just use `make sqlc`, which pins this
# golang-migrate/migrate for migrations
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
# a-h/templ for the server-rendered HTML pages (pkg/views/*.templ)
go install github.com/a-h/templ/cmd/templ@latestThe browser-facing pages are templ components in pkg/views/*.templ.
templ compiles each .templ into a committed *_templ.go file (so go build/CI
don't need the templ binary). After editing any .templ file, run make templ
(or templ generate) and commit the regenerated *_templ.go — CI fails if the
committed output is stale. make build/make run regenerate automatically.
The project uses Tailwind CSS v4 for styling. This requires Node.js (v18+):
npm install # install Tailwind dependenciesThe CSS is built automatically when you run make build or make run. For development with live reload:
make css-watch # in a separate terminalCreate a .env file (or .env.dev for development) in the project root:
# Database connection (required)
DATABASE_URL=postgresql://user:password@localhost:5432/identity?sslmode=disable
# HTTP server address (optional, defaults shown)
HTTP_ADDRESS=http://localhost:8080 # dev
# HTTP_ADDRESS=https://identity.example.com # production
# Session configuration (optional)
SESSION_SECRET=your-random-secret-key-at-least-32-chars-
Set DATABASE_URL to a non-prod database in
.env.dev(I usually copy the connection string for the dev branch of the main db in Neon, but you could also spin up local postgres). -
Run database migrations:
DATABASE_URL="..." make migrate-up -
Build the server and CLI:
make build
-
Start the server:
ENV=dev ./identity
The server will be available at
http://localhost:8080.
Users can register via the web UI:
http://localhost:8080/oauth/register
Or create users programmatically using the database directly.
Login page (standalone or via OAuth flow):
http://localhost:8080/login
Users can enable TOTP-based two-factor authentication from their account settings:
http://localhost:8080/oauth/account-settings
When MFA is enabled:
- User enters username/password as normal
- User is redirected to MFA verification page
- User enters 6-digit code from their authenticator app (Google Authenticator, Authy, etc.)
- On success, the OAuth flow continues as normal
MFA is off by default. Users can enable/disable it themselves via account settings.
Build binary:
make buildBuild docs:
make docsCreate a new migration:
# Use underscores in migration names
make migrate-new name=create_table_usersRun migrations:
DATABASE_URL="postgresql://..." make migrate-up
DATABASE_URL="postgresql://..." make migrate-downRegenerate sqlc queries and types.
make sqlcpkg/db is generated from db/migrations and db/queries — the migrations are
the schema, so this needs no database and no DATABASE_URL. The output is
committed, and CI runs make sqlc-check to fail if it drifts, the same way it
does for templ. The sqlc version is pinned in the Makefile, so run codegen
through make sqlc rather than a bare sqlc generate: a different version
regenerates the same code with a different banner, which CI reports as stale.
Migrations live in db/migrations as {version}_{name}.{up,down}.sql and are applied
with golang-migrate.
The server never migrates on startup. db/migrations/embed.go embeds the up
migrations so the binary knows the schema version it was built for, and startup calls
migrations.Verify, which refuses to start — naming the version mismatch — if the
database is not at exactly that version. A forgotten make migrate-up fails fast
instead of silently serving against a schema the code doesn't expect. Applying
migrations is always a deliberate, separate step:
- By hand, with the
migrateCLI via the Makefile — the usual local and staging/prod path:DATABASE_URL="postgresql://..." make migrate-up - From the binary, with the
migratesubcommand:It applies every pending up migration and exits 0; a database already at the latest version is a success, not an error. The migrations are embedded, so this needs noDATABASE_URL="postgresql://..." ./identity migrate # `auth-service migrate` in the image
.sqlfiles on disk and nomigrateCLI — just the binary and a database. It readsDATABASE_URLand nothing else, so the serving config (JWT_PRIVATE_KEYand friends) is irrelevant to it.
Running the binary with no arguments serves, exactly as it always has. The prod and
staging Deployments (k8s/) run it bare and never invoke migrate, so a missed
migration still fails closed there.
The subcommand exists for preview environments. A preview gets its own Neon database
branch cut from staging's schema, so a branch that adds a migration would otherwise start
against a schema that's behind and Verify would (correctly) refuse to start. bifrost's
service registry runs a preview's migrations by injecting a migrate initContainer that
runs the same image with the same env — so DATABASE_URL already points at that
preview's own branch — to completion before the app container starts:
migrate: ["/app/auth-service", "migrate"]-
Build the CLI:
make build
-
Create a public client (for SPAs like React apps):
ENV=dev ./identity-cli client create \ --name "My App (Dev)" \ --redirect-uris "http://localhost:5173/oauth/callback" \ --scopes "openid,profile,email"
Or create a confidential client (for backend services):
ENV=dev ./identity-cli client create \ --name "My Service" \ --redirect-uris "https://myservice.com/callback" \ --scopes "openid,profile,email" \ --confidential
Save the returned
client_id(andclient_secretif confidential).
./identity-cli client list- List all clients./identity-cli client get <client-id>- Get client details./identity-cli client update <client-id> --name "New Name"- Update client./identity-cli client delete <client-id>- Delete client
GET /oauth/authorize- Start OAuth authorization flow- Query params:
client_id,redirect_uri,state,scope,code_challenge,code_challenge_method
- Query params:
POST /oauth/token- Exchange authorization code for tokens- Form params:
grant_type,code,client_id,redirect_uri,code_verifier
- Form params:
POST /oauth/token- Refresh access token- Form params:
grant_type=refresh_token,refresh_token,client_id
- Form params:
GET /oauth/userinfo- Get user info from access token- Header:
Authorization: Bearer <access_token> - Returns:
sub,username,email,email_verified
- Header:
GET /login- Login page (standalone or via OAuth)POST /login- Process login credentialsGET /oauth/register- User registration pagePOST /oauth/register- Create new user accountGET /oauth/success- Post-login success page
GET /oauth/mfa- MFA code entry page (during login, if MFA enabled)POST /oauth/mfa- Validate MFA code and complete loginGET /oauth/mfa-setup- Setup MFA with QR code (from account settings)POST /oauth/mfa-setup- Verify setup code and enable MFAPOST /oauth/mfa-disable- Disable MFA (requires password + current MFA code)
GET /health- Health check endpointGET /docs- API documentation (Swagger/OpenAPI)
For a Single Page Application (SPA) using PKCE:
-
Generate PKCE values in your client:
const codeVerifier = generateRandomString(32); const codeChallenge = await sha256(codeVerifier);
-
Redirect to authorization endpoint:
http://localhost:8080/oauth/authorize? client_id=YOUR_CLIENT_ID& redirect_uri=http://localhost:5173/oauth/callback& response_type=code& state=RANDOM_STATE& scope=openid profile email& code_challenge=CODE_CHALLENGE& code_challenge_method=S256 -
User logs in and is redirected back with authorization code:
http://localhost:5173/oauth/callback? code=AUTHORIZATION_CODE& state=RANDOM_STATE -
Exchange code for tokens:
POST /oauth/token Content-Type: application/x-www-form-urlencoded grant_type=authorization_code& code=AUTHORIZATION_CODE& client_id=YOUR_CLIENT_ID& redirect_uri=http://localhost:5173/oauth/callback& code_verifier=CODE_VERIFIER
-
Receive tokens:
{ "access_token": "...", "refresh_token": "...", "expires_in": 3600, "token_type": "Bearer", "scope": ["openid", "profile", "email"] } -
Use access token to call protected APIs:
GET /oauth/userinfo Authorization: Bearer ACCESS_TOKEN
Start the server in development mode:
ENV=dev make runWatch logs for debugging:
ENV=dev LOG_LEVEL=debug ./identity