mirror of
https://github.com/quay/quay.git
synced 2026-01-26 06:21:37 +03:00
docs(oidc): add PKCE implementation and validation documentation - Add plans/oidc_pkcd.md with phased implementation plan - Add plans/oidc_pkce_validation.md with QA step-by-step local validation (Keycloak + Quay) Documentation includes: - Implementation phases and considerations - Local development setup with Keycloak - Step-by-step validation procedures - Configuration examples and troubleshooting Topic: pkce-docs
7.4 KiB
7.4 KiB
OIDC PKCE Support: Scope, Gaps, and Implementation Plan
Current State (from code review)
- OIDC exists via
oauth/oidc.pywith discovery, Authorization Code flow,id_tokenverification (JWKS), and optionaluserinfofetch. - Authorization URL is built by
OAuthService.get_auth_url()with params:client_id,redirect_uri,scope,state. - Token exchange is handled by
OAuthService.exchange_code(); payload includes:code,grant_type=authorization_code,redirect_uri,client_id,client_secret. - No PKCE support found: no
code_challenge,code_challenge_method, orcode_verifierusage. - Session
stateis CSRF-oriented and stable per session; not unique per auth request. - Additional OIDC usages: SSO JWT validation for API v1 (
auth/oauth.validate_sso_oauth_token), federated robot tokens (util/security/federated_robot_auth.py).
Goal
Add optional PKCE (RFC 7636) support to OIDC login flows. When enabled per OIDC provider:
- Include
code_challengeandcode_challenge_methodon the authorization request. - Include matching
code_verifierin the token exchange. - Support
S256(preferred) and fallbackplain. - Allow “public client” mode (omit client secret on token exchange) behind config.
Phased Implementation Plan
Phase 1: Config and Capability Wiring
- Add OIDC service-level config keys (per service block like
SOMEOIDC_LOGIN_CONFIG):USE_PKCE: true|false(default false)PKCE_METHOD: "S256"|"plain"(default "S256")PUBLIC_CLIENT: true|false(default false). When true, do not sendclient_secretand use HTTP Basic auth only if provider requires, otherwise none.
- Extend config schema (
util/config/schema.py) with the above keys under a generic OIDC provider object (documented but schema may currently not enumerate arbitrary OIDC providers; minimally document indocs/).
Deliverables:
- Schema/docs updates; no runtime behavior change yet.
Phase 2: PKCE Auth URL and Token Exchange
Minimal, backward-compatible API changes in the OAuth layer:
- In
oauth/base.py:- Extend
OAuthService.get_auth_url(..., extra_auth_params: dict = None)to merge extra query params. - Extend
OAuthService.exchange_code(..., extra_token_params: dict = None, ...)to merge extra body params. - Keep existing callers working (new params default to None).
- Extend
- Create
oauth/pkce.pyhelpers:generate_code_verifier(length=64)→ random RFC 7636-compliant string (43–128 chars).code_challenge(verifier, method="S256")→ base64url(SHA256(verifier)) orverifierfor plain.
- In
oauth/oidc.py(OIDCLoginService):- Add:
def pkce_enabled(self): return bool(self.config.get("USE_PKCE", False))def pkce_method(self): return self.config.get("PKCE_METHOD", "S256")
- Update
exchange_code_for_tokens(..., code_verifier=None, ...)to passextra_token_params={"code_verifier": code_verifier}when PKCE is enabled. - Respect
PUBLIC_CLIENT: when true, callexchange_code(..., client_auth=False)and omitclient_secret(current code already uses payload-based client auth; ensure secret is omitted ifPUBLIC_CLIENTis true).
- Add:
Endpoint-layer session handling (Flask) so services stay framework-agnostic:
- In
endpoints/api/user.py(ExternalLoginInformation.post):- If
pkce_enabled, generate acode_verifier, computecode_challenge, store verifier in session under a namespaced key, e.g.session[f"_oauth_pkce_{service_id}"] = {"verifier": v, "ts": now}. - Call
login_service.get_auth_url(..., extra_auth_params={"code_challenge": cc, "code_challenge_method": method}).
- If
- In
endpoints/oauth/login.pycallbacks (callback_func,attach_func,cli_token_func):- Retrieve and pop
session[f"_oauth_pkce_{service_id}"]if present andpkce_enabled. - Pass the verifier into
login_service.exchange_code_for_login(..., code_verifier=verifier)(plumb param throughexchange_code_for_tokens).
- Retrieve and pop
Security/robustness:
- Set short TTL (e.g., 10 minutes) on the stored verifier; drop if expired.
- Pop the verifier on first use to avoid reuse.
- Do not log verifier/challenge values.
Deliverables:
- Updated OAuth base API (non-breaking), OIDC service, endpoints, and new PKCE utility.
Phase 3: Tests
Unit tests (extend oauth/test/test_oidc.py or add oauth/test/test_oidc_pkce.py):
- Authorization URL contains
code_challengeand correct method whenUSE_PKCE=true. - Token POST includes
code_verifierwhenUSE_PKCE=true. - Works with both
S256andplain. PUBLIC_CLIENT=trueomitsclient_secretin token exchange.- Verifier is cleared from session after use and TTL enforced.
Integration-style tests with mock OIDC:
- Reuse existing HTTMock patterns in
oauth/test/test_oidc.pyto assert request body/query params; add variants for PKCE.
Phase 4: Documentation and Upgrade Notes
- Document new config keys and examples in
docs/andCLAUDE.mdDevelopment Tips. - Note security guidance: prefer
S256, treat verifiers as secrets, and encourage rotating per request.
Local Testing Guide
Option A: Local Keycloak with PKCE
- Start Quay dev env:
make local-dev-up
- Run Keycloak (example):
podman run --rm -p 8081:8080 quay.io/keycloak/keycloak:latest start-dev- Create a realm, client (Confidential or Public), set valid redirect URIs:
http://localhost:8080/oauth2/<service>/callback*. - Enable Standard Flow (Authorization Code). For Public client, skip client secret.
- Quay config (
conf/stack/config.yaml) example:
SOMEOIDC_LOGIN_CONFIG:
SERVICE_NAME: "Keycloak"
OIDC_SERVER: "http://localhost:8081/realms/<realm>"
CLIENT_ID: "quay-ui"
CLIENT_SECRET: "<secret>" # omit when PUBLIC_CLIENT: true
LOGIN_SCOPES: ["openid", "profile", "email"]
DEBUGGING: true # allow http for local
USE_PKCE: true
PKCE_METHOD: "S256"
PUBLIC_CLIENT: false # true if configured as Public in Keycloak
- Restart Quay:
podman restart quay-quay. - Trigger login URL (session-based):
CSRF=$(curl -s -c cookies.txt -b cookies.txt http://localhost:8080/csrf_token | jq -r .csrf_token)
curl -s -b cookies.txt -c cookies.txt -H "X-CSRF-Token: $CSRF" -H "Content-Type: application/json" \
-X POST http://localhost:8080/api/v1/externallogin/someoidc -d '{"kind":"login"}' | jq .auth_url -r
- Open returned
auth_urlin a browser, complete login. Verify successful redirect and Quay session.
Option B: Mock OIDC server for automated tests
- Leverage HTTMock-based unit tests (no external services) to validate PKCE parameters end-to-end.
Validation checks
- Authorization request has
code_challengeandcode_challenge_method. - Token request includes
code_verifierand succeeds. - For
PUBLIC_CLIENT=true, ensure client secret is not transmitted. - User logged in and visible via
/api/v1/user/with session cookies.
Risk/Edge Cases
- Session-wide CSRF token is stable; PKCE verifier is stored per-service to avoid mismatches. Encourage one login attempt at a time per service tab; TTL mitigates reuse.
- Some IdPs may require client auth even with PKCE; keep confidential client behavior by default.
- Ensure
userinfodisabled flows still work (extract fromid_token).
Rollout Plan
- Ship behind per-provider
USE_PKCEflag (default off). - Enable in staging with a single provider, monitor logs.
- Enable in production for providers requiring PKCE.
Estimated Effort
- Code changes: ~200–350 LOC.
- Unit tests: ~150–250 LOC.
- Docs: ~1–2 pages.