A lightweight OAuth2 / OpenID Connect (OIDC) Single Sign-On server written in Go, backed by SQLite.
- Authorization Code flow with PKCE (
S256andplain) - OpenID Connect ID tokens
- Refresh tokens
- RS256 JWT signing (auto-generated RSA key pair)
- User roles:
manager/staff - Admin API protected by a shared secret
- Zero external dependencies at runtime (SQLite embedded via CGo-free driver)
# Copy and edit environment config
cp .env.example .env # or edit .env directly
# Run
go run .The server starts on :8080 by default. The RSA private key is generated and saved to private.pem on first run.
All settings are read from environment variables (.env is loaded by your shell or a tool like direnv).
| Variable | Default | Description |
|---|---|---|
SERVER_ADDR |
:8080 |
TCP address the server listens on |
ISSUER |
http://localhost:8080 |
OIDC issuer URL (must be publicly reachable in prod) |
DATABASE_PATH |
sso.db |
Path to the SQLite database file |
ADMIN_SECRET |
change-me-in-production |
Secret for the Admin API (X-Admin-Secret header) |
Production note: Change
ADMIN_SECRETand setISSUERto your real HTTPS domain.
| Method | Path | Description |
|---|---|---|
GET |
/.well-known/openid-configuration |
OIDC discovery document |
GET |
/.well-known/jwks.json |
JSON Web Key Set (public key for verifying JWTs) |
| Method | Path | Description |
|---|---|---|
GET |
/authorize |
Displays the login UI for a client |
POST |
/authorize |
Authenticates the user and issues an authorization code |
Query parameters for GET /authorize:
| Parameter | Required | Description |
|---|---|---|
client_id |
Yes | Registered client ID |
redirect_uri |
Yes | Must match one of the client's registered redirect URIs |
response_type |
Yes | Must be code |
scope |
No | Space-separated scopes (openid, profile, email) |
state |
No | Opaque value for CSRF protection |
nonce |
No | Nonce included in the ID token |
code_challenge |
No | PKCE challenge value |
code_challenge_method |
No | S256 or plain |
| Method | Path | Description |
|---|---|---|
POST |
/token |
Exchange a code or refresh token for access/ID tokens |
Client authentication: HTTP Basic Auth (Authorization: Basic ...) or form fields (client_id + client_secret).
| Parameter | Required | Description |
|---|---|---|
grant_type |
Yes | authorization_code |
code |
Yes | Authorization code from /authorize |
redirect_uri |
Yes | Must match the one used in the authorize request |
code_verifier |
If PKCE used | PKCE code verifier |
| Parameter | Required | Description |
|---|---|---|
grant_type |
Yes | refresh_token |
refresh_token |
Yes | Refresh token from a previous token response |
Token response:
{
"access_token": "<JWT>",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "<opaque>",
"scope": "openid profile email",
"id_token": "<JWT>"
}Token lifetimes:
- Authorization codes: 10 minutes (single-use)
- Access tokens: 1 hour
- Refresh tokens: 30 days (single-use; a new one is issued on each refresh)
| Method | Path | Description |
|---|---|---|
GET / POST |
/userinfo |
Returns claims about the authenticated user |
Request header: Authorization: Bearer <access_token>
Response (claims depend on granted scopes):
{
"sub": "<user-id>",
"email": "user@example.com",
"name": "Alice",
"role": "manager"
}| Claim | Scope required |
|---|---|
sub |
always present |
email |
email |
name, role |
profile |
| Method | Path | Description |
|---|---|---|
POST |
/register |
Self-service user registration |
Request body:
{
"email": "user@example.com",
"password": "min8chars",
"name": "Alice",
"role": "staff"
}roleis optional; defaults tostaffif omitted or invalid.- Valid roles:
manager,staff. - Returns
409 Conflictif the email is already registered.
All admin endpoints require the X-Admin-Secret header matching the ADMIN_SECRET environment variable.
POST /admin/clients
X-Admin-Secret: <secret>
Content-Type: application/json
{
"client_id": "my-app",
"client_secret": "s3cr3t",
"name": "My Application",
"redirect_uris": ["https://myapp.example.com/callback"],
"scopes": "openid profile email"
}scopesdefaults toopenid profile emailif omitted.- Returns
201 Createdwith the created client (secret is never returned).
GET /admin/clients
X-Admin-Secret: <secret>
Returns an array of client objects (no secrets).
POST /admin/users
X-Admin-Secret: <secret>
Content-Type: application/json
{
"email": "alice@example.com",
"password": "min8chars",
"name": "Alice",
"role": "manager"
}GET /admin/users
X-Admin-Secret: <secret>
PATCH /admin/users/{id}/role
X-Admin-Secret: <secret>
Content-Type: application/json
{ "role": "manager" }Valid roles: manager, staff.
Tokens are signed with RS256 using the key in private.pem.
Access token claims:
| Claim | Value |
|---|---|
iss |
Issuer URL |
sub |
User ID |
aud |
[client_id] |
iat |
Issued at (Unix) |
exp |
Expiry (Unix, +1h) |
scope |
Granted scopes |
role |
User role |
ID token claims (when openid scope is granted):
| Claim | Scope |
|---|---|
iss, sub, aud, iat, exp |
always |
nonce |
if provided in the authorize request |
email |
email scope |
name, role |
profile scope |
SQLite database with four tables:
users—id,email,password_hash(bcrypt),name,role,created_atclients—id,client_id,client_secret_hash(bcrypt),name,redirect_uris(JSON),scopes,created_atauthorization_codes— single-use, 10-minute TTL, supports PKCErefresh_tokens— single-use (rotated on use), 30-day TTL
curl -X POST http://localhost:8080/admin/clients \
-H "X-Admin-Secret: change-me-in-production" \
-H "Content-Type: application/json" \
-d '{
"client_id": "my-app",
"client_secret": "supersecret",
"name": "My App",
"redirect_uris": ["http://localhost:3000/callback"]
}'curl -X POST http://localhost:8080/admin/users \
-H "X-Admin-Secret: change-me-in-production" \
-H "Content-Type: application/json" \
-d '{"email":"alice@example.com","password":"password123","name":"Alice","role":"staff"}'Redirect the user's browser to:
http://localhost:8080/authorize
?client_id=my-app
&redirect_uri=http://localhost:3000/callback
&response_type=code
&scope=openid%20profile%20email
&state=random-csrf-token
curl -X POST http://localhost:8080/token \
-u "my-app:supersecret" \
-d "grant_type=authorization_code" \
-d "code=<code>" \
-d "redirect_uri=http://localhost:3000/callback"curl http://localhost:8080/userinfo \
-H "Authorization: Bearer <access_token>"OAuth2 errors follow RFC 6749:
{
"error": "invalid_grant",
"error_description": "authorization code expired"
}Common error codes: invalid_request, invalid_client, invalid_grant, unsupported_grant_type, server_error, unauthorized, conflict, not_found.