Skip to content

fix(oidc): validate ID tokens + PKCE/nonce, stop email auto-link, tokens off URL (OC1/OC2/OH4/OH5)#57

Open
PenguinzTech wants to merge 2 commits into
fix/auth-secrets-configfrom
fix/oidc-hardening
Open

fix(oidc): validate ID tokens + PKCE/nonce, stop email auto-link, tokens off URL (OC1/OC2/OH4/OH5)#57
PenguinzTech wants to merge 2 commits into
fix/auth-secrets-configfrom
fix/oidc-hardening

Conversation

@PenguinzTech

@PenguinzTech PenguinzTech commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Security fixes (Track A2) — OIDC/SSO hardening

Stacked on #56 (base = fix/auth-secrets-config); merge that first. Ultimately targets release/v0.1.x.

Fixes account-takeover-class findings in services/flask-backend/app/oidc.py:

  • OC1 (CRITICAL) — callback did NO id_token validation and trusted the userinfo sub/email. Now validates the id_token via cached JWKS: signature, iss, aud, exp, nonce. Adds PKCE (S256) + nonce to the authorize/callback flow.
  • OC2 (CRITICAL) — auto-link/auto-provision now gated on email_verified (+ OIDC_AUTO_LINK_VERIFIED_EMAIL flag, default off). No more silent email-based linking to existing accounts.
  • OH4 (HIGH) — tokens returned as httpOnly/Secure/SameSite cookies, not in the redirect URL query string.
  • OH5 (HIGH) — identity linking only via authenticated OIDC round-trip; removed body-supplied external_sub/external_email.

Schema: adds nullable oidc_providers.issuer, user_oidc_links.email_verified.
Deferred to A5 (by design): _encrypt_secret fail-open, update_provider premium gating.

Tests

Full backend suite: 403 passed, 1 skipped, 90.16% coverage. mypy --strict clean.

🤖 Generated with Claude Code

Summary by Sourcery

Harden OIDC authentication and linking flows with ID token validation, PKCE/nonce, safer auto-linking, and secure token delivery.

New Features:

  • Support PKCE (S256) and nonce parameters in OIDC authorization and callback flows.
  • Introduce an authenticated OIDC-based identity linking flow initiated per provider slug.

Bug Fixes:

  • Validate OIDC ID tokens via provider JWKS, checking signature and core claims (iss, aud, exp, nonce) before trusting user identity.
  • Gate email-based auto-linking and auto-provisioning on provider-verified email addresses, with a configuration flag to enable auto-link.
  • Stop accepting externally supplied subject/email values for linking, requiring a full authenticated OIDC round-trip instead.
  • Return access and refresh tokens via secure httpOnly cookies instead of exposing them in redirect URLs.

Enhancements:

  • Add JWKS caching for OIDC providers to reduce repeated key fetches and improve resilience to transient JWKS failures.
  • Extend OIDC provider and user-link schemas with issuer and email_verified fields to support stronger validation and auditing.

…tokens off URL

Fixes account-takeover and token-leak findings in the OIDC/SSO consumer:
- OC1: callback now validates the id_token (JWKS signature, iss/aud/exp/nonce)
  instead of blindly trusting the userinfo response; identity derives from
  validated claims. Adds PKCE (S256) + nonce to authorize/callback.
- OC2: auto-link/auto-provision gated on email_verified (+ OIDC_AUTO_LINK_
  VERIFIED_EMAIL flag, default off); no more silent email-based account linking.
- OH4: access/refresh tokens returned as httpOnly Secure SameSite cookies, not
  in the redirect URL query string.
- OH5: identity linking only via authenticated OIDC round-trip; removed
  body-supplied external_sub/external_email linking.

Adds nullable columns oidc_providers.issuer, user_oidc_links.email_verified.
_encrypt_secret fail-open and update_provider gating deferred to A5.

Full backend suite: 403 passed, 1 skipped, 90.16% coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@PenguinzTech PenguinzTech added this to the v0.1.x milestone Jul 14, 2026
@PenguinzTech PenguinzTech added the type:security Security fix label Jul 14, 2026
@sourcery-ai

sourcery-ai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Reviewer's Guide

Hardens the OIDC flow by adding PKCE + nonce, full ID token validation via JWKS, secure cookie delivery of tokens, and a proper authenticated OIDC-based identity linking flow, along with minimal schema/config wiring to support issuer and email_verified tracking.

Sequence diagram for hardened OIDC authorize/callback and link flow

sequenceDiagram
    actor User
    participant Browser
    participant oidc_bp
    participant OIDCProvider
    participant JWKS_Endpoint

    User ->> Browser: Open /oidc/link/{provider_slug} (authenticated)
    Browser ->> oidc_bp: GET /oidc/link/{provider_slug}
    oidc_bp ->> oidc_bp: initiate_link_identity
    oidc_bp ->> oidc_bp: _generate_pkce_pair
    oidc_bp ->> oidc_bp: _generate_nonce
    oidc_bp ->> Browser: redirect to OIDC authorization_endpoint

    User ->> Browser: Approve OIDC login
    OIDCProvider ->> Browser: redirect to /oidc/{slug}/callback?code&state
    Browser ->> oidc_bp: GET /oidc/{slug}/callback
    oidc_bp ->> oidc_bp: callback
    oidc_bp ->> oidc_bp: validate state
    oidc_bp ->> oidc_bp: read oidc_code_verifier, oidc_nonce
    oidc_bp ->> OIDCProvider: AsyncOAuth2Client.fetch_token
    oidc_bp ->> OIDCProvider: GET discovery_url
    OIDCProvider -->> oidc_bp: token_endpoint, jwks_uri, issuer
    oidc_bp ->> OIDCProvider: fetch_token (code, code_verifier)
    OIDCProvider -->> oidc_bp: id_token

    oidc_bp ->> JWKS_Endpoint: _fetch_jwks(jwks_uri)
    JWKS_Endpoint -->> oidc_bp: JWKS keys
    oidc_bp ->> oidc_bp: _validate_id_token(id_token, keys, client_id, issuer, nonce)

    alt link_mode set in session
        oidc_bp ->> oidc_bp: create user_oidc_links row (email_verified)
        oidc_bp ->> Browser: redirect /account?linked=ok
    else standard login
        oidc_bp ->> oidc_bp: find user_oidc_links or get_user_by_email
        oidc_bp ->> oidc_bp: create_user if needed and email_verified
        oidc_bp ->> oidc_bp: create_access_token
        oidc_bp ->> oidc_bp: create_refresh_token
        oidc_bp ->> Browser: redirect /login?sso=ok with httpOnly cookies
    end
Loading

Entity relationship diagram for new OIDC issuer and email_verified fields

erDiagram
    USERS {
        int id
    }
    OIDC_PROVIDERS {
        int id
        string issuer
    }
    USER_OIDC_LINKS {
        int id
        int user_id
        int provider_id
        boolean email_verified
    }

    USERS ||--o{ USER_OIDC_LINKS : has
    OIDC_PROVIDERS ||--o{ USER_OIDC_LINKS : links
Loading

File-Level Changes

Change Details Files
Add PKCE and nonce support to OIDC authorization and callback flows for replay protection.
  • Introduce helpers to generate PKCE code_verifier/code_challenge and a cryptographic nonce.
  • Store state, PKCE verifier, and nonce in the session during authorization, and validate/pull them back in the callback.
  • Pass code_challenge/nonce to the provider on authorize and code_verifier on token exchange, rejecting callbacks missing these values.
services/flask-backend/app/oidc.py
Implement JWKS-based ID token validation and enforce issuer/audience/expiry/nonce checks.
  • Add a simple in-memory JWKS cache with TTL and an async fetch helper using httpx.
  • Add an ID token validation helper that finds the correct key by kid, verifies the JWS signature, and validates exp, iss, aud, and nonce.
  • Modify the callback handler to fetch JWKS via discovery, require id_token in the token response, and validate it before using any claims.
services/flask-backend/app/oidc.py
Gate auto-linking/provisioning on verified email and expose configuration for that behavior.
  • Persist email_verified on user_oidc_links and populate it from ID token claims.
  • Change auto-link/auto-provision logic to only operate when email is verified and OIDC_AUTO_LINK_VERIFIED_EMAIL is enabled for linking existing users.
  • Reject login when no user can be found/created due to unverified email, with explicit logging and error responses.
services/flask-backend/app/oidc.py
Stop returning tokens in URLs and instead issue them as secure, httpOnly cookies.
  • Change callback success redirect to a fixed frontend URL parameter (sso=ok) without embedding tokens.
  • Wrap the redirect in a Response that sets access and refresh tokens as httpOnly, Secure, SameSite=Lax cookies with appropriate paths and lifetimes.
  • Import Response from Quart to support cookie-setting on redirects.
services/flask-backend/app/oidc.py
Rework identity linking to require a full authenticated OIDC round-trip instead of trusting request body data.
  • Replace the POST /oidc/link endpoint with GET /oidc/link/<provider_slug> that initiates an OIDC authorization request for the logged-in user, using the same PKCE/nonce machinery.
  • Track link_mode and link_user_id in the session so the callback can distinguish linking from normal login.
  • In callback, when link_mode is active, verify the user exists and email is verified, then create a user_oidc_links row and redirect to an account page instead of issuing tokens.
  • Keep DELETE /oidc/link/<provider_slug> semantics for unlinking unchanged aside from using provider slug lookup at initiation time.
services/flask-backend/app/oidc.py
Extend OIDC schema and provider update API to track issuer and email verification status.
  • Add issuer column to oidc_providers and email_verified column to user_oidc_links in both runtime table definitions and SQL init for supported backends.
  • Allow issuer to be updated via the update_provider API, and fetch issuer from discovery in the authorize/callback flow for validation.
  • Add logging around state mismatch, missing issuer/JWKS, ID token validation failures, deactivated users, and other security-relevant conditions.
services/flask-backend/app/oidc.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai 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.

Hey - I've found 3 issues, and left some high level feedback:

  • The _fetch_jwks return type annotation (dict[str, Any]) doesn’t match its actual behavior (it consistently returns a list of key dicts), which can confuse callers and type checkers; consider updating the annotation and docstring to list[dict[str, Any]] (and removing the unused global _jwks_cache type mention of keys as a dict).
  • In _validate_id_token, the base64 padding logic for the header (header_b64 += '=' * (4 - len(header_b64) % 4)) will incorrectly add four '=' characters when the length is already a multiple of 4; guard this with a conditional so padding is only added when len(header_b64) % 4 != 0.
  • The new set_cookie(..., secure=True, ...) on the OIDC callback response may break local HTTP development; consider deriving the secure flag from configuration or environment (e.g., only enabling it when running behind HTTPS in production).
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The `_fetch_jwks` return type annotation (`dict[str, Any]`) doesn’t match its actual behavior (it consistently returns a list of key dicts), which can confuse callers and type checkers; consider updating the annotation and docstring to `list[dict[str, Any]]` (and removing the unused global `_jwks_cache` type mention of `keys` as a dict).
- In `_validate_id_token`, the base64 padding logic for the header (`header_b64 += '=' * (4 - len(header_b64) % 4)`) will incorrectly add four '=' characters when the length is already a multiple of 4; guard this with a conditional so padding is only added when `len(header_b64) % 4 != 0`.
- The new `set_cookie(..., secure=True, ...)` on the OIDC callback response may break local HTTP development; consider deriving the `secure` flag from configuration or environment (e.g., only enabling it when running behind HTTPS in production).

## Individual Comments

### Comment 1
<location path="services/flask-backend/app/oidc.py" line_range="214-223" />
<code_context>
+# --- JWKS & ID Token Validation ---
+
+
+async def _fetch_jwks(provider_id: int, jwks_uri: str) -> dict[str, Any]:
+    """
+    Fetch and cache JWKS from provider.
+
+    Returns: {"keys": [...]}
+    """
+    global _jwks_cache
+
+    # Check cache
+    if provider_id in _jwks_cache:
+        cached = _jwks_cache[provider_id]
+        if time.time() - cached["fetched_at"] < _JWKS_TTL_SECONDS:
+            return cached["keys"]
+
+    # Fetch fresh
+    import httpx
+
+    try:
+        async with httpx.AsyncClient() as http:
+            resp = await http.get(jwks_uri, timeout=10)
+            data = resp.json()
+            _jwks_cache[provider_id] = {
+                "fetched_at": time.time(),
+                "keys": data.get("keys", []),
+            }
+            return data.get("keys", [])
+    except Exception as e:
+        logger.error(f"JWKS fetch failed for provider {provider_id}: {e}")
</code_context>
<issue_to_address>
**issue (bug_risk):** Return typing/docstring for _fetch_jwks don’t match actual behaviour and HTTP errors aren’t checked

The function returns a list (`data.get("keys", [])`), but the annotation/docstring claim it returns a dict. Since callers treat `jwks_keys` as a list, update the return type and docstring to `list[dict[str, Any]]` for consistency.

We also call `resp.json()` without checking `resp.status_code` or handling invalid JSON. This can produce confusing errors and cache bad responses. Please check for a successful status (e.g. `2xx`), handle JSON errors, and avoid updating the cache on failure.
</issue_to_address>

### Comment 2
<location path="services/flask-backend/app/oidc.py" line_range="269" />
<code_context>
+        import json
+
+        header_b64 = id_token.split(".")[0]
+        header_b64 += "=" * (4 - len(header_b64) % 4)
+        header = json.loads(base64.urlsafe_b64decode(header_b64))
+    except Exception as e:
</code_context>
<issue_to_address>
**issue (bug_risk):** Base64 padding logic can over-pad when the length is already aligned

If `len(header_b64) % 4 == 0`, this will still append four `=` characters and produce an invalid base64 string. Instead, compute the required padding with:

```python
pad_len = (-len(header_b64)) % 4
header_b64 += "=" * pad_len
```

so that already correctly padded values are left unchanged.
</issue_to_address>

### Comment 3
<location path="services/flask-backend/app/oidc.py" line_range="290-293" />
<code_context>
+
+    # Verify signature and decode
+    try:
+        jws = JsonWebSignature()
+        jwk = JsonWebKey.import_key(jwks_key_data)
+        claims = jws.deserialize_compact(id_token, jwk)
+        claims_dict = json.loads(claims)
+    except Exception as e:
+        raise ValueError(f"ID token signature verification failed: {e}")
</code_context>
<issue_to_address>
**issue (bug_risk):** Authlib JWS deserialization result is likely misused, leading to incorrect claim parsing

`JsonWebSignature.deserialize_compact` returns a dict like `{"header": ..., "payload": b"..."}`, not a JSON string. Using that value directly in `json.loads(claims)` is incorrect and will error or mis-parse. Instead, extract the payload and decode it:

```python
result = jws.deserialize_compact(id_token, jwk)
payload = result["payload"]  # bytes
claims_dict = json.loads(payload)
```

Alternatively, consider using an authlib JWT helper that handles both verification and decoding in one step.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +214 to +223
async def _fetch_jwks(provider_id: int, jwks_uri: str) -> dict[str, Any]:
"""
Fetch and cache JWKS from provider.

Returns: {"keys": [...]}
"""
global _jwks_cache

# Check cache
if provider_id in _jwks_cache:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue (bug_risk): Return typing/docstring for _fetch_jwks don’t match actual behaviour and HTTP errors aren’t checked

The function returns a list (data.get("keys", [])), but the annotation/docstring claim it returns a dict. Since callers treat jwks_keys as a list, update the return type and docstring to list[dict[str, Any]] for consistency.

We also call resp.json() without checking resp.status_code or handling invalid JSON. This can produce confusing errors and cache bad responses. Please check for a successful status (e.g. 2xx), handle JSON errors, and avoid updating the cache on failure.

import json

header_b64 = id_token.split(".")[0]
header_b64 += "=" * (4 - len(header_b64) % 4)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue (bug_risk): Base64 padding logic can over-pad when the length is already aligned

If len(header_b64) % 4 == 0, this will still append four = characters and produce an invalid base64 string. Instead, compute the required padding with:

pad_len = (-len(header_b64)) % 4
header_b64 += "=" * pad_len

so that already correctly padded values are left unchanged.

Comment thread services/flask-backend/app/oidc.py Outdated
Comment on lines +290 to +293
jws = JsonWebSignature()
jwk = JsonWebKey.import_key(jwks_key_data)
claims = jws.deserialize_compact(id_token, jwk)
claims_dict = json.loads(claims)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue (bug_risk): Authlib JWS deserialization result is likely misused, leading to incorrect claim parsing

JsonWebSignature.deserialize_compact returns a dict like {"header": ..., "payload": b"..."}, not a JSON string. Using that value directly in json.loads(claims) is incorrect and will error or mis-parse. Instead, extract the payload and decode it:

result = jws.deserialize_compact(id_token, jwk)
payload = result["payload"]  # bytes
claims_dict = json.loads(payload)

Alternatively, consider using an authlib JWT helper that handles both verification and decoding in one step.

Follow-up hardening on the id_token validator: JsonWebSignature was constructed
with no algorithm allowlist, so it honored whatever alg the token header claimed.
An attacker could forge a token with alg:HS256 and sign it using the provider's
PUBLIC JWKS key as the HMAC secret (or alg:none) and have it accepted.

- Restrict to asymmetric algs only: RS256/384/512, ES256/384/512, PS256/384/512.
- Defensively reject the alg in the token header before verifying (blocks HS*/none).
- Reject symmetric (oct) JWKS keys so a public key can't be used as an HMAC secret.
- authlib 1.3.0: JsonWebSignature(algorithms=[...]); payload via JWSObject.payload.

Regression tests (8/8): forged HS256-with-public-key rejected, alg:none rejected,
oct key rejected, valid RS256 accepted, plus iss/exp/kid/nonce checks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type:security Security fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant