Skip to content

Authentication

dim145 edited this page Apr 26, 2026 · 1 revision

Authentication

MangaCollector supports two sign-in flows:

  • Google OAuth 2.0 — quickest setup if you have a Google Cloud account.
  • Generic OpenID Connect — works with any compliant IdP (Defguard, Auth0, Keycloak, Authentik, Authelia, Okta, …).

Both write to the same users table and produce identical sessions. The choice is purely operational.


How the flow works

Sign-in screen

  1. User clicks the sign-in button on /log-in.
  2. Browser redirects to the provider's authorisation endpoint with the redirect_uri set to ${FRONTEND_URL}/auth/callback.
  3. User authenticates on the provider's domain.
  4. Provider redirects back with an ?code=…&state=… query string.
  5. Backend exchanges the code for an ID token, validates it, looks up or creates the user in Postgres.
  6. Backend issues a session cookie (Lax, HttpOnly, Secure in production) tied to a row in sessions.
  7. Browser is redirected to /dashboard.

The session lives in Postgres via tower-sessions-sqlx-store. There's no JWT — sessions are server-side and revocable from the Active sessions modal in the profile dropdown.


Google OAuth 2.0

The fastest path. Five-minute setup.

Create the OAuth client

  1. Open the Google Cloud Console — Credentials
  2. Create credentials → OAuth client ID
  3. Application type: Web application
  4. Authorised redirect URIs: add ${FRONTEND_URL}/auth/callback for every environment you plan to deploy. Examples:
    • http://localhost:12000/auth/callback (dev)
    • https://mangacollector.example.com/auth/callback (prod)
  5. Create. Copy the client ID and the client secret.

Configure the backend

AUTH_MODE=google
AUTH_CLIENT_ID=<from-google-console>
AUTH_CLIENT_SECRET=<from-google-console>
AUTH_NAME=Google
AUTH_ICON=google
SESSION_SECRET=<openssl rand -base64 48>
FRONTEND_URL=https://mangacollector.example.com

That's it. Restart the backend and you can sign in.

Restricting which Google accounts can sign in

Out of the box the backend accepts any successful Google sign-in — anyone with a Gmail account can create a profile. Two ways to lock that down:

  • Hosted-domain restriction — for Google Workspace tenants, set up the consent screen as Internal instead of External. Google then refuses sign-ins from outside your domain at the provider level.
  • External access list — a database-level allowlist isn't shipped today; for personal deployments, restricting OAuth credentials to your own Google account during creation is sufficient.

OpenID Connect (any IdP)

Works with any provider that exposes /.well-known/openid-configuration with an OIDC-compliant document.

Configure on the IdP side

The exact UI varies by provider, but you'll always end up with:

  • a Client ID + Client Secret
  • a list of allowed redirect URIs — add ${FRONTEND_URL}/auth/callback
  • the issuer URL (your IdP's base URL where it serves /.well-known/openid-configuration)

The required scopes are openid email profile. The backend doesn't request custom scopes.

Configure the backend

AUTH_MODE=openidconnect
AUTH_ISSUER=https://idp.example.com
AUTH_CLIENT_ID=<from-idp>
AUTH_CLIENT_SECRET=<from-idp>
AUTH_NAME="My SSO"
AUTH_ICON=https://idp.example.com/logo.svg
SESSION_SECRET=<openssl rand -base64 48>
FRONTEND_URL=https://mangacollector.example.com

Tested IdPs

These have been verified end-to-end:

  • Defguard (the dev compose example uses this)
  • Authentik
  • Keycloak (≥ 21)
  • Auth0

If your IdP exposes the discovery document at a non-standard path, set AUTH_ISSUER_BASE_PATH to the prefix that gets appended after the issuer host. Example for Defguard:

AUTH_ISSUER=https://idp.example.com
AUTH_ISSUER_BASE_PATH=/api/v1/oauth/

The backend will fetch https://idp.example.com/api/v1/oauth/.well-known/openid-configuration instead of the default location.

Customising the sign-in button

AUTH_NAME and AUTH_ICON control the button shown on /log-in:

AUTH_NAME="Acme SSO"
AUTH_ICON=https://acme.example.com/sso.svg

AUTH_ICON accepts the literal value google (built-in glyph) or any URL pointing to a PNG / SVG.


Sessions

Sessions are persisted in Postgres (sessions table). Cookies are:

  • HttpOnly (no JS access)
  • SameSite=Lax (mitigates CSRF for cross-site GETs)
  • Secure (only when FRONTEND_URL starts with https://)
  • 14-day rolling lifetime — every authenticated request bumps the expiry

Active sessions UI

The profile-button dropdown exposes Active sessions — opens a modal listing every device currently signed in (UA-based icon, IP address, last seen). The user can revoke any session from there; the affected device is forced to re-sign-in on its next request.

Forced sign-out

A user can be force-signed-out everywhere by deleting their rows from the sessions table:

DELETE FROM sessions WHERE user_id = (SELECT id FROM users WHERE email = '...');

The next request from any of their tabs receives a 401, and the frontend redirects to /log-in.


Troubleshooting

Symptom Likely cause
OAuth callback returns 400 redirect_uri_mismatch The redirect URI in your IdP doesn't match ${FRONTEND_URL}/auth/callback exactly — including scheme and trailing slash.
Sign-in succeeds but you land back on /log-in Check Set-Cookie is actually being set. In production this requires https:// for Secure cookies; with HTTP, set FRONTEND_URL=http://... and the cookie will drop the Secure flag.
Failed to fetch OIDC discovery document in logs AUTH_ISSUER (+ optional AUTH_ISSUER_BASE_PATH) doesn't resolve to a /.well-known/openid-configuration. Try opening it in your browser.
invalid_client from the IdP Client ID or secret is wrong, or the client is configured for a different redirect_uri.

Clone this wiki locally