Skip to content

feat(auth): add OIDC/SSO login support (e.g. Authentik) - #592

Open
KeplerAeroIT wants to merge 6 commits into
C4illin:mainfrom
KeplerAeroIT:feat/oidc-sso-login
Open

feat(auth): add OIDC/SSO login support (e.g. Authentik)#592
KeplerAeroIT wants to merge 6 commits into
C4illin:mainfrom
KeplerAeroIT:feat/oidc-sso-login

Conversation

@KeplerAeroIT

@KeplerAeroIT KeplerAeroIT commented Jul 31, 2026

Copy link
Copy Markdown

Summary

Adds OpenID Connect (OIDC) login as an alternative/companion to the existing local email+password auth, so ConvertX can be deployed behind an identity provider such as Authentik, Keycloak, Zitadel, or any standards-compliant OIDC issuer — useful for orgs that want centralized login/SSO instead of managing separate ConvertX accounts.

It uses the standard authorization-code flow with PKCE (via openid-client) and slots in alongside the existing session system: a successful OIDC login mints the exact same JWT cookie that password login does, so every existing authenticated route (history, conversions, account, etc.) works completely unchanged.

How it works

  • GET /login/oidc — starts the flow (PKCE verifier/state/nonce stored in a short-lived, scoped cookie) and redirects to the provider.
  • GET /login/oidc/callback — exchanges the code, validates state/nonce, reads the ID token claims (falling back to the userinfo endpoint if the email claim isn't in the ID token), then:
    • looks up the local user by OIDC sub,
    • auto-links to an existing local account with a matching email if one exists,
    • otherwise auto-provisions a new local user (with an unusable random password, since local password login stays disabled for that account).
  • The /login page shows a "Login with {OIDC_NAME}" button below the existing form (separated by a divider) whenever OIDC is configured.

Configuration

All new behavior is opt-in and controlled entirely by environment variables — nothing changes for existing deployments that don't set them.

Name Default Description
OIDC_ISSUER The provider's issuer URL, e.g. https://authentik.example.com/application/o/convertx/
OIDC_CLIENT_ID OAuth2/OIDC client ID
OIDC_CLIENT_SECRET OAuth2/OIDC client secret
OIDC_REDIRECT_URI Public callback URL registered with the provider, e.g. https://convertx.example.com/login/oidc/callback
OIDC_SCOPES openid profile email Scopes requested from the provider
OIDC_NAME SSO Button label, e.g. Authentik
OIDC_ONLY false Hides local email/password login and registration entirely, so OIDC is the only way in

Setting OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, and OIDC_REDIRECT_URI together enables the button; nothing else needs to change.

Example (Authentik)

  1. Create an OAuth2/OpenID Provider in Authentik (confidential client), set its redirect URI to https://convertx.example.com/login/oidc/callback, and bind it to an Application.
  2. Set the env vars above using the client ID/secret from that provider and the provider's issuer URL.
  3. Restart ConvertX — the login page now shows a "Login with Authentik" button.

Full walkthrough and the env var table are added to the README.

Database

Adds a nullable oidc_sub column to the users table (schema version bumped to 2, with an automatic migration for existing installs — no action needed on upgrade).

Testing

  • tsc --noEmit / eslint — no new errors introduced (verified against the existing pre-existing warnings/errors baseline).
  • Built and ran the full Docker image locally against a real Authentik instance: verified OIDC discovery succeeds at startup, GET /login/oidc redirects to Authentik with correct client_id/PKCE challenge/state/nonce/redirect_uri, and a full login round-trip (including auto-provisioning the first user) works end-to-end.
  • Verified the OIDC_ONLY gate hides local login/registration and bypasses the first-run local setup page, and that the hybrid case (local form + SSO button, separated by a divider) renders correctly.

Notes

  • Local password login is unaffected when OIDC isn't configured.
  • When OIDC_ONLY is not set, existing local accounts keep working alongside SSO, and accounts are linked by email on first SSO login.

Summary by cubic

Add OpenID Connect (OIDC) SSO login alongside email/password using Authorization Code + PKCE via openid-client. It issues the same JWT cookie, auto-migrates the DB to v3, and adds a prod compose + .env.example for simpler config.

  • New Features

    • OIDC routes: GET /login/oidc and /login/oidc/callback.
    • Login page shows “Login with {OIDC_NAME}”; OIDC_ONLY=true hides the local form and Register links.
    • Account handling: match by (issuer, sub); auto-link by verified email; auto-provision on first SSO login.
    • Deployment: added .env.example and docker-compose.prod.yml; README documents OIDC setup and env vars.
  • Bug Fixes

    • Fix redirect_uri mismatches behind reverse proxies by building the callback URL from OIDC_REDIRECT_URI.
    • Make OIDC discovery non-blocking with auto-retry; only show the SSO button when discovery succeeds.
    • Hardened auth: safe cookie format/validation for the OIDC flow; link by verified email only; prevent duplicate first-logins with a unique (issuer, sub) identity and ON CONFLICT insert.
    • Correct TZ example in compose.yaml (Asia/Kolkata) to avoid runtime errors.

Written for commit eede2cb. Summary will update on new commits.

Review in cubic

Adds a generic OpenID Connect authorization-code + PKCE login flow
(via openid-client) alongside the existing local email/password auth,
so ConvertX can be deployed behind an identity provider like Authentik,
Keycloak, or any standards-compliant OIDC issuer.

- New GET /login/oidc and /login/oidc/callback routes handle the
  authorization redirect and token exchange, then mint the same JWT
  session cookie issued by password login, so every existing
  authenticated route works unchanged.
- Users are matched by OIDC subject, auto-linked to an existing local
  account by email, or auto-provisioned on first login.
- New OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET,
  OIDC_REDIRECT_URI, OIDC_SCOPES, OIDC_NAME and OIDC_ONLY env vars
  configure and optionally gate out local login entirely.
- README documents the new env vars and a step-by-step Authentik
  provider/application setup.
@github-actions github-actions Bot added Feature and removed Feature labels Jul 31, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 12 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread compose.yaml Outdated
Comment thread src/pages/oidc.tsx Outdated
Comment thread src/pages/oidc.tsx Outdated
Comment thread src/pages/oidc.tsx Outdated
Comment thread src/pages/oidc.tsx Outdated
Comment thread src/pages/user.tsx
Comment thread src/pages/user.tsx Outdated
… races)

- Fix the actual production bug: Elysia's cookie parser auto-JSON.parses
  any cookie value shaped like {...}/[...], so the JSON-encoded oidcFlow
  cookie was silently turned into an object and rejected by its own
  t.String() schema before the handler ever ran. Switch to a dot-delimited
  string (PKCE verifier/state/nonce are base64url, so "." is a safe
  delimiter) and validate its shape explicitly instead of a bare JSON.parse.
- Store the OIDC issuer alongside sub and match on the (issuer, sub) pair,
  since sub is only guaranteed unique within a single issuer.
- Only auto-link an OIDC login to an existing local account when the
  provider has verified the email; an unverified/attacker-set email claim
  could otherwise hijack an existing account.
- Add a unique index on (oidc_issuer, oidc_sub) and use
  INSERT ... ON CONFLICT DO NOTHING so two concurrent first-logins for the
  same new identity can't provision duplicate user rows.
- Move OIDC discovery into a shared helper exposing isOidcReady(), and use
  it (rather than just checking that the env vars are set) to decide
  whether the login page shows the SSO button - so a failed discovery at
  startup doesn't advertise a provider that can never complete a login.
- Fix compose.yaml's TZ typo (Asia/Koltaka -> Asia/Kolkata), which
  otherwise throws at runtime and breaks the history page's date formatting.
- Gate the Register link behind OIDC_ONLY everywhere Header is rendered,
  so OIDC-only deployments don't show a dead local-registration link.
Adds a production compose file that pulls the published image and loads
all configuration from a single .env file via env_file, plus a fully
documented .env.example listing every environment variable ConvertX
supports (auth, OIDC/SSO, app behavior, ffmpeg tuning). Lets users manage
config in one place instead of inlining it into the compose file.
@KeplerAeroIT

Copy link
Copy Markdown
Author

Addressed all findings from @cubic-dev-ai's review in ba87632:

  • compose.yaml TZ typo (P1): fixed Asia/Koltaka -> Asia/Kolkata. Confirmed the invalid zone does throw at runtime and breaks the history page's date formatting.
  • Dead Register link under OIDC_ONLY (P3): accountRegistration is now ACCOUNT_REGISTRATION && !OIDC_ONLY everywhere Header is rendered.
  • SSO button shown even when discovery failed (P3): moved discovery into a shared helper exposing isOidcReady(), and the login page now gates the button/divider on actual discovery success instead of just env-var presence.
  • Malformed oidcFlow cookie could 500 (P2): this was actually the same bug hit in real-world testing - Elysia's cookie parser auto-JSON.parses any cookie value shaped like {...}/[...], so the JSON-encoded flow cookie was silently turned into an object and rejected by its own schema before the handler ever ran. Switched to a dot-delimited string (PKCE verifier/state/nonce are base64url, so . is a safe delimiter) with explicit shape validation instead of a bare JSON.parse.
  • Cross-issuer sub collision (P1): identity is now keyed on the (issuer, sub) pair, not sub alone.
  • Unverified-email account takeover (P1): auto-linking to an existing local account by email now requires the provider to have asserted email_verified.
  • Race condition on concurrent first logins (P2): added a unique index on (oidc_issuer, oidc_sub) and switched provisioning to INSERT ... ON CONFLICT DO NOTHING + re-select, so concurrent logins for the same new identity can't create duplicate rows.

Also added docker-compose.prod.yml + a fully documented .env.example for a single-file production config workflow.

All changes verified with tsc --noEmit/eslint (no new errors) and a live end-to-end run against a real Authentik instance, including reproducing the exact cookie-validation crash from testing and confirming it's now a clean redirect.

@github-actions github-actions Bot added Feature and removed Feature labels Jul 31, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 10 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/helpers/oidcClient.ts Outdated
…non-blocking discovery

- openid-client derives the redirect_uri it sends to the token endpoint
  from the callback request's own URL, stripped of query/hash - not from
  the configured OIDC_REDIRECT_URI. Behind a reverse proxy or tunnel that
  terminates TLS and forwards to the origin over plain HTTP (Cloudflare
  Tunnel, nginx, etc.), the app sees "http://" even though the browser
  used "https://", so the redirect_uri sent to the token endpoint never
  matches what's registered with the provider, and the provider rejects
  the exchange. Build the callback URL from the trusted, admin-configured
  OIDC_REDIRECT_URI instead, carrying over only the real request's query
  string (code, state).
- Move OIDC discovery off the module's top-level await and into a
  fire-and-forget retry loop. discovery()'s own default timeout is 30s,
  so an unreachable/stalled issuer was delaying the entire server from
  binding its port and failing health checks - including for deployments
  not otherwise depending on OIDC being up yet. isOidcReady() now reflects
  completion, and a transient IdP outage recovers automatically via retry
  without needing a container restart.
@KeplerAeroIT

Copy link
Copy Markdown
Author

Two more fixes in e62830c:

  • Redirect URI mismatch behind reverse proxies/tunnels: found this live while deploying through a Cloudflare Tunnel. openid-client derives the redirect_uri it sends to the token endpoint from the callback request's own URL (stripped of query/hash), not from the configured OIDC_REDIRECT_URI. Behind any proxy/tunnel that terminates TLS and forwards to the origin over plain HTTP, the app saw http:// even though the browser used https://, so the token exchange's redirect_uri never matched what's registered with the provider (confirmed via the provider's own server-side logs: expected https://..., got http://...). Now the callback URL passed to authorizationCodeGrant is built from the trusted OIDC_REDIRECT_URI, with only the query string (code, state) taken from the actual request.
  • @cubic-dev-ai: valid finding on oidcClient.ts - discovery was on a top-level await, and discovery()'s own default timeout is 30s, so an unreachable/stalled issuer delayed the whole server from binding its port (and failing health checks), even for deployments not otherwise depending on OIDC yet. Moved it to a fire-and-forget retry loop; verified by starting a container with an intentionally unreachable issuer - the server now responds in ~2s instead of blocking, and discovery retries in the background until it succeeds.

Both verified against a real Authentik instance / real proxy setup, plus tsc --noEmit/eslint clean.

@cubic-dev-ai

cubic-dev-ai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Two more fixes in e62830c:

  • Redirect URI mismatch behind reverse proxies/tunnels: found this live while deploying through a Cloudflare Tunnel. openid-client derives the redirect_uri it sends to the token endpoint from the callback request's own URL (stripped of query/hash), not from the configured OIDC_REDIRECT_URI. Behind any proxy/tunnel that terminates TLS and forwards to the origin over plain HTTP, the app saw http:// even though the browser used https://, so the token exchange's redirect_uri never matched what's registered with the provider (confirmed via the provider's own server-side logs: expected https://..., got http://...). Now the callback URL passed to authorizationCodeGrant is built from the trusted OIDC_REDIRECT_URI, with only the query string (code, state) taken from the actual request.
  • @cubic-dev-ai: valid finding on oidcClient.ts - discovery was on a top-level await, and discovery()'s own default timeout is 30s, so an unreachable/stalled issuer delayed the whole server from binding its port (and failing health checks), even for deployments not otherwise depending on OIDC yet. Moved it to a fire-and-forget retry loop; verified by starting a container with an intentionally unreachable issuer - the server now responds in ~2s instead of blocking, and discovery retries in the background until it succeeds.

...

@KeplerAeroIT Both look right to me. The redirect_URI fix is the kind of thing you only catch when you actually run it through a real TLS-terminating proxy — openid-client's default behavior makes sense for a non-proxied setup but it's a footgun behind a tunnel. Building the callback URL from the configured URI with the real request's query string is the correct approach.

The discovery fix is also solid. Blocking server startup on a 30s timeout (the default for discovery()) is a hard no for production, especially for deployments that don't even use OIDC but have the env vars set. The fire-and-forget retry loop with a shared isOidcReady() guard handles both cases cleanly.

Good catches, clean fixes.

Tip: get faster answers by chatting with cubic’s review copilot in the review UI

@github-actions github-actions Bot added Feature and removed Feature labels Jul 31, 2026
@github-actions github-actions Bot added Feature and removed Feature labels Aug 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant