Skip to content

feat(auth): add self-service OIDC console login - #1854

Merged
lavkushry merged 1 commit into
mainfrom
feat/oidc-console-login-v2
Jul 11, 2026
Merged

feat(auth): add self-service OIDC console login#1854
lavkushry merged 1 commit into
mainfrom
feat/oidc-console-login-v2

Conversation

@lavkushry

Copy link
Copy Markdown
Owner

Summary

Picks up the roadmap item "OIDC/SAML for console/admin" — a scoped MVP slice (self-service OIDC login/linking), not the full item (no SAML, no multi-IdP, no per-SSO-user attribution/revocation yet — all called out explicitly below and in the docs, not silently omitted).

Supersedes #1853, which is closed: its history contained a since-removed test-only RSA private-key PEM fixture that gitleaks correctly flags on every commit in that branch's range (even after a later commit removed it — gitleaks scans the full commit history, not just the final diff). This PR is a clean squash of that branch's final state onto a fresh branch, so the flagged string never appears in this branch's history at all.

The gateway previously only verified externally-issued JWTs (shared-secret HMAC via AEGIS_JWT_SECRET) — this is the first thing in the gateway that mints its own tokens.

  • Routes: GET /v1/auth/oidc/login (plain login), POST /v1/oidc/link/start (authenticated; starts a "link my identity to my tenant" flow), GET /v1/auth/oidc/callback (IdP redirect target). PKCE, discovery, JWKS caching, and ID-token signature/issuer/audience/expiry/nonce verification are all handled by the openidconnect crate, not hand-rolled.
  • No auto-provisioning: a new oidc_identities table (UNIQUE(issuer, subject)) maps an external identity to exactly one tenant, populated only via the self-service link flow. An unrecognized identity at plain login fails closed (#error=identity_not_linked), it never creates or guesses a tenant.
  • Entirely gated: unset any of AEGIS_OIDC_ISSUER_URL/CLIENT_ID/CLIENT_SECRET/REDIRECT_URL/UI_REDIRECT_URL and every route returns 501 — no other gateway behavior changes.
  • ui-next: a "Sign in with SSO" / "Link SSO identity to this tenant" section in Settings, and an OidcCallbackHandler that picks up the #access_token=/#error= URL fragment on load and populates the store.

Security review caught and fixed a CRITICAL bug before merge

Because this is new authentication surface, I ran a security-auditor review before finalizing. It found a critical cross-tenant identity-linking vulnerability: the flow-state cookie carrying linking_tenant_id (which tenant a verified identity gets bound to) was plain base64 JSON with no signature. An attacker could forge the cookie with an arbitrary victim linking_tenant_id, complete a normal OIDC login for their own identity, and bind their own account to the victim's tenant — link/start's bearer-token gate only controlled what the server wrote into the cookie, not what the client presented back at the callback.

Fix: the flow-state cookie is now HMAC-tagged with the same AEGIS_JWT_SECRET used to mint the JWT (OidcFlowState::encode/decode now require the key; a cookie that doesn't verify is treated as absent, not partially trusted). Added a regression test (callback_rejects_a_flow_state_cookie_not_signed_by_the_gateway) that forges a cookie with a different key and asserts the identity is never linked.

Also fixed, from the same review:

  • A non-atomic check-then-insert race in link_oidc_identity — the INSERT now carries the uniqueness guarantee directly (oidc_identities.UNIQUE(issuer, subject)); the SELECT only runs to classify an actual conflict.
  • Added a startup warning: OIDC configured without AEGIS_JWT_REQUIRED=true means the pre-existing raw Bearer tenant_<id> fallback still bypasses OIDC entirely.
  • Documented (not silently left) two accepted-for-now gaps: the minted JWT carries only tenant authority with no per-SSO-user attribution/revocation, and the flow-state cookie has no Secure attribute (matches the existing aegis_csrf precedent; deviating would break the documented plain-HTTP local/CI dev flow).

Test plan

  • cargo check / cargo fmt --check / cargo clippy --workspace --all-targets -D warnings — clean
  • cargo test --workspace -- --test-threads=1 — all pass
  • 12 Rust tests for the OIDC login/link/callback routes, including a full mock-IdP round trip (real RSA-signed ID tokens generated at test time — no committed key fixture, PKCE/state/nonce flow) proving: successful login+link mints a valid JWT, an unlinked identity fails closed, a CSRF state mismatch is rejected, and — the critical fix — a forged flow-state cookie is rejected and never results in a tenant link
  • 4 storage-layer tests for oidc_identities (link/lookup round-trip, idempotent re-link to same tenant, rejected re-link to a different tenant, tenant-scoped listing)
  • bun run build / bunx tsc --noEmit / bun run lint / bun test (ui-next) — all pass, including new unit tests for the callback-fragment parser

Roadmap item "OIDC/SAML for console/admin" — a scoped MVP slice, not the
full item. The gateway previously only verified externally-issued JWTs
(shared-secret HMAC); this is the first thing that mints its own tokens.

- GET /v1/auth/oidc/login, POST /v1/oidc/link/start, GET /v1/auth/oidc/callback
  (openidconnect crate handles PKCE/discovery/JWKS/ID-token verification).
- Self-service identity linking only: an authenticated caller links their own
  OIDC identity to their tenant (oidc_identities, UNIQUE(issuer, subject)).
  No auto-provisioning — an unrecognized identity at login fails closed.
- Entirely gated on AEGIS_OIDC_* + AEGIS_JWT_SECRET; unset means every route
  returns 501, no other behavior changes.
- ui-next: a "Sign in with SSO" / "Link SSO identity" section in Settings,
  and an OidcCallbackHandler that picks up the #access_token=/#error=
  fragment on load.

Security review (security-auditor agent) caught a CRITICAL cross-tenant
identity-linking vulnerability before merge: the flow-state cookie carrying
`linking_tenant_id` was unsigned, so a caller could forge it to bind their
own IdP identity to a victim tenant despite link/start's own bearer-token
gate — that gate only controlled what the server wrote, not what the client
presented back at the callback. Fixed by HMAC-tagging the cookie with the
same AEGIS_JWT_SECRET used to mint the JWT, with a regression test proving
a forged cookie is now rejected. Also fixed a related non-atomic
check-then-insert race in link_oidc_identity (INSERT now carries the
uniqueness guarantee directly; the SELECT only runs on conflict).

The mock IdP's RSA keypair used in tests is generated at test-run time
(rsa + rand dev-dependencies) rather than a committed PEM fixture, so
there's no private-key-shaped string literal in source history at all.

Known, documented limitations (not silently omitted): the minted JWT
carries only tenant authority with no per-SSO-user attribution or
revocation; the pre-existing raw `Bearer tenant_<id>` fallback still
bypasses OIDC unless AEGIS_JWT_REQUIRED=true (now warned about at startup);
single IdP per gateway; no Secure cookie flag (matches the existing
aegis_csrf precedent, documented as worth revisiting).
Copilot AI review requested due to automatic review settings July 11, 2026 18:24
@ecc-tools

ecc-tools Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

ECC bundle files are already tracked in this repository. Skipping generation of another bundle PR.

@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@lavkushry, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 39 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8c9d3e16-252a-416a-af4c-61852e466f09

📥 Commits

Reviewing files that changed from the base of the PR and between 7dd8d9c and dbcdf4c.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (22)
  • docs/Implementation_Status.md
  • docs/current-vs-roadmap.md
  • lib/api/src/models.rs
  • lib/storage/migrations/0047_oidc_identities.sql
  • lib/storage/migrations_postgres/0032_oidc_identities.sql
  • lib/storage/src/db/mod.rs
  • lib/storage/src/db/oidc.rs
  • lib/storage/src/sqlite.rs
  • lib/storage/src/traits.rs
  • src/Cargo.toml
  • src/src/lib.rs
  • src/src/main.rs
  • src/src/oidc.rs
  • src/src/routes/authorize.rs
  • src/src/routes/mod.rs
  • src/src/routes/oidc.rs
  • src/src/routes/openapi.rs
  • ui-next/src/app/OidcCallbackHandler.tsx
  • ui-next/src/app/providers.tsx
  • ui-next/src/domains/oidc.test.ts
  • ui-next/src/domains/oidc.ts
  • ui-next/src/features/settings/SettingsPage.tsx
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/oidc-console-login-v2

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request implements a beta OIDC console login feature, adding backend support for OIDC discovery, PKCE-based authorization flows, and database-backed identity linking (for both SQLite and Postgres), alongside frontend integration in the console UI. Feedback on these changes highlights two main improvement opportunities: configuring a timeout on the HTTP client used for OIDC discovery to prevent gateway startup from blocking indefinitely on network hangs, and using TextDecoder instead of atob directly in the frontend to robustly decode JWT payloads containing multi-byte UTF-8 characters.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/src/oidc.rs
Comment on lines +176 to +179
let http_client = openidconnect::reqwest::Client::builder()
.redirect(openidconnect::reqwest::redirect::Policy::none())
.build()
.map_err(|e| OidcDiscoveryError::Discovery(e.to_string()))?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The reqwest::Client is built without a timeout. Since OIDC discovery is performed during gateway startup in main, any network hang or firewall packet drop when connecting to the IdP will cause the entire gateway startup to block indefinitely. This can lead to deployment failures and liveness/readiness probe timeouts. Configuring a reasonable timeout on the HTTP client ensures that discovery failures fail fast and allow the gateway to start up with OIDC disabled as intended.

Suggested change
let http_client = openidconnect::reqwest::Client::builder()
.redirect(openidconnect::reqwest::redirect::Policy::none())
.build()
.map_err(|e| OidcDiscoveryError::Discovery(e.to_string()))?;
let http_client = openidconnect::reqwest::Client::builder()
.redirect(openidconnect::reqwest::redirect::Policy::none())
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| OidcDiscoveryError::Discovery(e.to_string()))?;

const payload = token.split(".")[1];
if (!payload) return undefined;
const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
const json = atob(base64);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

atob decodes base64 strings into a binary string using Latin-1 encoding, which does not correctly handle multi-byte UTF-8 characters. If any claim in the JWT (even if not tenant_id itself) contains non-ASCII characters, JSON.parse(atob(base64)) can fail with a syntax error or corrupt the characters, causing the decoding to fail and return undefined. Using TextDecoder ensures robust UTF-8 decoding in modern browsers.

    const json = new TextDecoder().decode(
      Uint8Array.from(atob(base64), (c) => c.charCodeAt(0))
    );

@lavkushry
lavkushry merged commit 15f80e3 into main Jul 11, 2026
32 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants