No description
  • Go 97.5%
  • Go Template 2%
  • Shell 0.4%
  • Dockerfile 0.1%
Find a file
nhpro 2f79163bb8
All checks were successful
Docs / wiki (push) Successful in 34s
CI / backend (push) Successful in 35s
CI / tag-format (push) Successful in 2s
Release / release (push) Successful in 1m14s
ci: force the Gitea token in GoReleaser
The Forgejo runner injects GITHUB_TOKEN into every job, so GoReleaser
saw it alongside GITEA_TOKEN and aborted with 'multiple tokens found'.
force_token: gitea makes it ignore the injected one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 12:05:30 +02:00
.forgejo/workflows ci: generate the API wiki with widdershins 2026-09-11 12:52:28 +02:00
.serena docs: document silent SSO via IdP session cookie 2026-06-26 12:33:36 +02:00
account feat(account): wrap RequestDeletion in db.Transaction 2026-06-06 11:36:54 +02:00
actiontoken refactor(token): use request_id claim instead of cid+rdr 2026-06-02 16:18:43 +02:00
audit refactor(audit): typed Action string for compile-time safety 2026-06-05 16:50:34 +02:00
client fix(oauth): block SSRF via request_uris 2026-07-24 13:55:12 +02:00
config chore: run gofmt -w on whole repo 2026-06-05 16:08:36 +02:00
crypto feat(crypto): add multi-scheme password verification with lazy rehash 2026-07-23 11:26:05 +02:00
database refactor(database): RegisterCleanup registry for new TTL tables 2026-06-05 16:44:47 +02:00
email fix(email): use implicit TLS on port 465 and keep the configured port 2026-10-07 12:00:57 +02:00
examples docs: document IAM migration from Logto 2026-07-23 11:26:05 +02:00
federation fix(federation): bind social-login state to the browser 2026-07-24 13:55:12 +02:00
importer fix(importer): fall back to username for display_name when Logto name empty 2026-07-23 19:56:11 +02:00
invitation feat(invitation): wrap Create in db.Transaction with outbox enqueue 2026-06-06 11:26:12 +02:00
m2m fix(m2m): shield protected-role users from privilege escalation 2026-07-24 13:55:12 +02:00
metrics feat(metrics): outbound email queue depth gauge + delivery counter 2026-06-06 11:38:00 +02:00
mfa fix(mfa): prevent TOTP replay within validation window (Vuln 12) 2026-06-02 11:33:58 +02:00
middleware fix(reauth): make step-up tokens single-use 2026-07-24 13:55:12 +02:00
migrations fix(migrations): verify email of existing admins 2026-07-01 11:52:18 +02:00
model fix(oauth): bind prompt=none to session, enforce response_type, one-time client-assertion jti 2026-07-24 13:55:12 +02:00
oauth fix(oauth): bind prompt=none to session, enforce response_type, one-time client-assertion jti 2026-07-24 13:55:12 +02:00
oidc feat(oidc): clear SSO cookies on end_session 2026-06-26 12:33:36 +02:00
passkey fix(passkey): surface CloneWarning and reject cloned assertions (Vuln 7) 2026-06-02 11:26:24 +02:00
password chore: run gofmt -w on whole repo 2026-06-05 16:08:36 +02:00
pkg fix(oauth): block SSRF via request_uris 2026-07-24 13:55:12 +02:00
policy refactor(policy): consolidate adapters into policy/adapter.go 2026-06-05 16:39:53 +02:00
rbac fix(rbac): require write permission for admin mutations 2026-07-24 13:55:12 +02:00
reauth chore: run gofmt -w on whole repo 2026-06-05 16:08:36 +02:00
regform feat(regform): wire public schema endpoint and apply extras on register and federation 2026-05-20 14:39:03 +02:00
resource fix(resource): pipe token_format from CreateInput/UpdateInput onto the model 2026-05-19 16:02:07 +02:00
scripts ci: generate the API wiki with widdershins 2026-09-11 12:52:28 +02:00
session feat(session): add opaque IdP SSO cookie token to sessions 2026-06-26 12:33:25 +02:00
testutil chore: run gofmt -w on whole repo 2026-06-05 16:08:36 +02:00
user fix(user): revoke sessions on password reset, close login enumeration, guard admin self-delete 2026-07-24 13:55:12 +02:00
.example.env feat: add Dockerfile and example environment config 2026-04-13 19:55:28 +02:00
.gitignore ci: generate the API wiki with widdershins 2026-09-11 12:52:28 +02:00
.goreleaser.yaml ci: force the Gitea token in GoReleaser 2026-10-07 12:05:30 +02:00
CHANGELOG.md ci: force the Gitea token in GoReleaser 2026-10-07 12:05:30 +02:00
cmd_import.go feat(cli): add import subcommand for IAM migration 2026-07-23 11:26:05 +02:00
config.yaml fix(email): use implicit TLS on port 465 and keep the configured port 2026-10-07 12:00:57 +02:00
Dockerfile ci: cut releases with GoReleaser on tag push 2026-09-11 12:52:11 +02:00
Dockerfile.goreleaser ci: cut releases with GoReleaser on tag push 2026-09-11 12:52:11 +02:00
go.mod chore(deps): update Go dependencies 2026-07-06 20:30:08 +02:00
go.sum chore(deps): update Go dependencies 2026-07-06 20:30:08 +02:00
main.go docs: declare the https scheme in the swagger annotations 2026-09-11 12:52:28 +02:00
main_test.go feat(health): readiness probe verifies DB + active JWKS key 2026-06-05 10:48:22 +02:00
MIGRATION.md docs: document IAM migration from Logto 2026-07-23 11:26:05 +02:00
README.md docs: document IAM migration from Logto 2026-07-23 11:26:05 +02:00
swagger_gate_test.go chore: run gofmt -w on whole repo 2026-06-05 16:08:36 +02:00
VERSIONING.md ci: cut releases with GoReleaser on tag push 2026-09-11 12:52:11 +02:00

OrionAuth

orion-auth-backend

OAuth 2.0 + OpenID Connect authorization server.
Single Go binary · PostgreSQL · optional SMTP · ready to run behind any reverse proxy.


What it is

A full OIDC Identity Provider written in Go. It speaks the core OAuth 2.0 and OpenID Connect specs, ships a complete user-management surface (registration, MFA, passkeys, federation, RBAC, audit), and is operated through two adjacent SPAs (orion-auth-frontend for admins, orion-auth-authui for end users).

This README is the operator manual: how to run it, how to integrate a client against it, and how to keep it healthy in production. It is not an exhaustive code walkthrough — for that, the Swagger UI is mounted at /swagger/* in debug mode, and the .serena/memories/ directory holds architectural notes.

Standards coverage at a glance

Spec Status
OAuth 2.0 (RFC 6749) authorization code, refresh, client credentials ✓
OAuth 2.0 PKCE (RFC 7636), S256 only ✓
OAuth 2.0 Device Authorization Grant (RFC 8628) ✓
OAuth 2.0 Pushed Authorization Requests (RFC 9126) ✓
OAuth 2.0 JWT-Secured Authorization Request — JAR (RFC 9101) ✓
OAuth 2.0 Authorization Server Issuer Identification (RFC 9207) ✓
OAuth 2.0 Token Introspection (RFC 7662) ✓
OAuth 2.0 Token Revocation (RFC 7009) ✓
OIDC Core 1.0 — discovery, ID tokens (RS256), UserInfo, hybrid flows ✓
OIDC Initiating User Registration 1.0 (prompt=create) ✓
OIDC Session Management 1.0 — check_session, front-channel logout ✓
OIDC Back-Channel Logout 1.0 ✓
OIDC RP-Initiated Logout 1.0 ✓
OIDC Dynamic Client Registration 1.0 (RFC 7591) ✓
OIDC Pairwise Subject Identifiers (per-sector) ✓
Client auth: client_secret_basic, client_secret_post, client_secret_jwt, private_key_jwt, none ✓
DPoP (RFC 9449), mTLS client auth (RFC 8705), RAR (RFC 9396), CIBA — roadmap

Table of contents


Quickstart (docker-compose)

# docker-compose.yml — single-node dev/staging
services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_DB: orionauth
      POSTGRES_USER: orionauth
      POSTGRES_PASSWORD: change-me
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U orionauth"]
      interval: 5s

  mailhog:
    image: mailhog/mailhog
    ports: ["8025:8025"]      # web UI

  auth:
    build: .
    depends_on:
      postgres: { condition: service_healthy }
    environment:
      ORION_SERVER_MODE: release
      ORION_DATABASE_HOST: postgres
      ORION_DATABASE_PASSWORD: change-me
      ORION_SMTP_HOST: mailhog
      ORION_SMTP_PORT: "1025"
      ORION_SMTP_TLS: "false"     # acceptable against mailhog only
      ORION_ISSUER: http://localhost:8080
      ORION_AUTH_HMAC_SECRET_ENCRYPTION_KEY: <see secret generation>
      ORION_AUTH_ACTION_TOKEN_SIGNING_KEY:   <see secret generation>
      ORION_PAIRWISE_SALT:                   <see secret generation>
    ports: ["8080:8080"]

volumes:
  pgdata:

Generate the three required secrets before first boot:

# 32-byte AES-256 key (base64) — seals client_secret_jwt + federation secrets at rest
openssl rand -base64 32

# 32-byte HMAC key (base64) — signs verify-email action tokens
openssl rand -base64 32

# 32-byte salt (hex) — derives per-sector pairwise OIDC subs
openssl rand -hex 32

Then:

docker compose up -d
curl http://localhost:8080/ready
# {"status":"ok","checks":{"database":"ok","jwks":"ok"}}

The discovery document is served at http://localhost:8080/.well-known/openid-configuration.


Configuration reference

Configuration loads in this order (later wins):

  1. config.yaml in the working directory
  2. /etc/orionauth/config.yaml
  3. Environment variables prefixed ORION_ (e.g. database.host → ORION_DATABASE_HOST)

In release mode (ORION_SERVER_MODE=release), config.Validate() refuses startup when any required value is empty or holds the shipped placeholder.

Required in release mode

Key Env var Purpose
server.mode ORION_SERVER_MODE Set to release to enforce invariants and emit HSTS.
database.password ORION_DATABASE_PASSWORD Postgres connection password.
auth.hmac_secret_encryption_key ORION_AUTH_HMAC_SECRET_ENCRYPTION_KEY base64 32-byte AES-256; seals client_secret_jwt + federation secrets.
auth.action_token_signing_key ORION_AUTH_ACTION_TOKEN_SIGNING_KEY base64 32+-byte HMAC; signs verify-email links. Rotating invalidates outstanding links.
issuer ORION_ISSUER Public HTTPS URL the discovery document advertises.
pairwise_salt ORION_PAIRWISE_SALT Random hex; derives pairwise OIDC sub values. Do not rotate after launch — breaks downstream RP identity continuity.
Key Purpose
server.trusted_proxies List of IP/CIDR ranges Gin trusts to set X-Forwarded-For. Without this, c.ClientIP() reads the header verbatim — audit logs and per-IP rate-limit buckets become attacker-controlled. Example: ["10.0.0.0/8", "172.16.0.0/12"].
server.trusted_platform Set when fronted by a known CDN (e.g. CF-Connecting-IP for Cloudflare). Overrides trusted_proxies for IP lookup.
cors.allowed_origins Browser SPAs allowed to call the API. Wildcard + credentials is rejected; configure exact origins.
database.sslmode require when Postgres is on a separate host. disable is only safe inside a private network (docker bridge / pod network).
smtp.tls true in production. false only when targeting MailHog or another loopback test server.

Auth + token lifetimes

Key Default Notes
auth.access_token_ttl 1h Short — clients refresh as needed.
auth.refresh_token_ttl 24h Family-tracked with reuse detection.
auth.session_ttl 720h (30d) Default browser session.
auth.session_extended_ttl 720h Used when remember_me=true. Admin override via settings table.
auth.auth_code_ttl 60s OAuth authorization code lifetime.
auth.device_code_ttl 10m RFC 8628 device flow.
auth.password_min_length 8 Hard floor; admin password policy can be stricter.
auth.max_failed_attempts 5 Login lockout threshold per user.
auth.lockout_duration 15m Fixed lockout window after the threshold.

Argon2id (password hashing)

Defaults match the OWASP recommendation. Increase memory on beefy hosts to slow brute-force further:

argon2:
  memory: 65536      # 64 MiB
  iterations: 3
  parallelism: 4
  salt_length: 16
  key_length: 32

Integrating an OAuth / OIDC client

1. Register a client

Two paths:

  • Manual via the AdminUI (orion-auth-frontend) or by inserting into oauth_clients directly. Set redirect_uris, allowed_scopes, token_endpoint_auth_method.
  • Dynamic Client Registration (RFC 7591): POST /register with a metadata JSON document. Gate the endpoint behind auth.dcr_initial_access_token if you don't want it open to the internet.

2. Discover endpoints

curl https://auth.example.test/.well-known/openid-configuration

The response advertises every endpoint you'll call: authorization_endpoint, token_endpoint, userinfo_endpoint, jwks_uri, end_session_endpoint, device_authorization_endpoint, introspection_endpoint, revocation_endpoint.

Public clients (SPAs, native apps) must use PKCE with S256:

GET /ui/authorize?
    response_type=code
    &client_id=<your-id>
    &redirect_uri=https://your-app/callback
    &scope=openid profile email
    &code_challenge=<S256 challenge>
    &code_challenge_method=S256
    &state=<csrf>
    &nonce=<replay guard>

User completes login + consent → redirect back with code + state. Exchange the code at the token endpoint:

POST /token
  grant_type=authorization_code
  code=<code>
  redirect_uri=<must match the authorize call>
  code_verifier=<PKCE verifier>
  client_id=<your-id>           # public clients
  # OR
  client_secret=<secret>        # client_secret_post
  client_assertion=<JWT>        # private_key_jwt / client_secret_jwt

4. Initiating signup (OIDC prompt=create)

Add prompt=create to the authorize URL. The user lands on the signup form instead of login, completes signup, clicks the verify-email link in their inbox, and is auto-logged-in into your app. See .serena/memories/oauth_flows_detail.md for the full handshake.


Federation (social login)

Register an external provider (Discord, GitHub, or any generic OIDC/OAuth2 IdP) via the AdminUI or by inserting into federation_providers. Required fields: name, type (oidc | oauth2), client_id, client_secret, authorization_url, token_url, userinfo_url, scopes.

Account takeover policy (hard rule): the server never auto-links an external identity to a local account on matching email. Users who already have a local account must sign in locally first, then link the provider from their profile. This is enforced server-side; do not weaken it.


Operating in production

Health and readiness

Endpoint Purpose Probe target
GET /health Liveness: process is up. Always 200 if the server is responding. Kubernetes livenessProbe.
GET /ready Readiness: DB ping + active JWKS signing key. 503 if either fails. Kubernetes readinessProbe, ALB target health.

Metrics and dashboards

Scrape GET /metrics (Prometheus exposition format). Series exposed:

orionauth_login_total{result}                          # success|fail|locked|mfa_required|email_not_verified
orionauth_oauth_token_issued_total{grant_type}         # authorization_code, refresh_token, ...
orionauth_http_request_duration_seconds_bucket{method,route,status}
go_* / process_*                                       # runtime collectors

Useful PromQL starters:

# Login failure ratio (5 min)
sum(rate(orionauth_login_total{result!="success"}[5m]))
  / sum(rate(orionauth_login_total[5m]))

# p95 token-endpoint latency
histogram_quantile(0.95,
  sum by (le) (rate(orionauth_http_request_duration_seconds_bucket{route="/token"}[5m])))

For outbox depth (no Prometheus series yet), query the DB directly:

SELECT status, COUNT(*) FROM outbound_emails GROUP BY status;

Key rotation

Key Rotation policy Procedure
RSA signing key (JWKS) Rotate on operator action; old key is kept 24 h for the verification window. AdminUI → Settings → Rotate signing key.
auth.action_token_signing_key Rotate on suspected leak. Invalidates all outstanding verify-email links — users mid-signup must click "resend". Generate a new key, update the env var / config, restart. No DB change.
auth.hmac_secret_encryption_key Rotate carefully; re-encrypts every stored client_secret_jwt seal. Manual SQL until a rotation tool ships.
pairwise_salt Do not rotate after launch. Changing it changes every pairwise sub, breaking downstream RP identity mapping. n/a

Backup and restore

# Backup: pg_dump nightly + archive WAL for PITR
pg_dump -Fc -d orionauth -f orionauth-$(date +%Y%m%d).dump

# Restore (DB must be empty)
createdb orionauth
pg_restore -d orionauth orionauth-20260605.dump

What you lose without backups:

  • Every user account, password hash, MFA enrollment.
  • Every OAuth client + secret.
  • Every audit log line (no other persistence layer).
  • Every pending verify-email row (users will need to request resend).

There is no built-in user-data export endpoint yet (GDPR Art. 15 is on the roadmap). Until then, query the relevant tables directly: users, audit_logs, federation_links, sessions, mfa_methods.

Email outbox

outbound_emails is the persistent retry queue. Every Send* call inserts a row; a background worker drains it via SMTP with exponential backoff (2 m → 4 m → 8 m → 16 m → cap 1 h, 5 attempts by default — total retry window ~30 min).

Operational queries:

-- Pending backlog
SELECT status, COUNT(*) FROM outbound_emails GROUP BY status;

-- Stuck rows (still retrying, will fail eventually)
SELECT id, recipient, subject, attempts, last_error, next_retry_at
FROM outbound_emails
WHERE status = 'pending' AND attempts >= 3
ORDER BY next_retry_at;

-- Replay a failed row
UPDATE outbound_emails
SET status='pending', attempts=0, next_retry_at=NOW(), last_error=NULL
WHERE id = '<uuid>';

Retention: the periodic cleanup job purges sent and failed rows older than 7 days.


Troubleshooting

Startup error: auth.action_token_signing_key is empty You're in release mode without the key set. Generate one (openssl rand -base64 32) and export ORION_AUTH_ACTION_TOKEN_SIGNING_KEY.

Login returns 403 email_not_verified The default gate refuses login until email is verified. Either ask the user to click the verify link, POST /api/v1/auth/resend-verification, or disable the gate via AdminUI → Settings → Require email verification.

OAuth client gets invalid_grant on code exchange Usual suspects: PKCE code_verifier mismatch, expired code (60 s default), reused code (one-shot), or redirect_uri not matching exactly.

Verify-email link 302s to the error page The action-token JWT failed validation: expired (24 h), tampered, or signed with a prior key. The user must request a new link.

Behind a reverse proxy, audit logs show the proxy IP server.trusted_proxies is not set. Add the proxy IP/CIDR and restart; c.ClientIP() will then deduce the real client from X-Forwarded-For.

Outbox worker silent on email delivery Inspect outbound_emails directly. Rows stuck pending with attempts=0 mean the worker isn't running (check logs for "email outbox worker started"). Rows with last_error mean SMTP config is wrong.


For deeper architectural context, see the Serena memories in .serena/memories/ (project_overview, oauth_flows_detail, policy_engine, services_and_repositories, …).