Add Keycloak OmniAuth login with Authorization Code + PKCE #134
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "115-keycloak-omniauth-login"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Changes
omniauth_openid_connect(> 0.8) and> 1.0)omniauth-rails_csrf_protection(current_user,logged_in?,authenticate_user!,current_user_has_role?,keycloak_configured?helpers exposed to views/auth/keycloak/callback), failure route, DELETE/logoutTest Plan
Review Checklist
Related Notes
landscaping-assistant-- landscaping assistant projectQA Review -- PR #134
Findings
1. Duplicate
keycloak_configured?definitions (low severity)keycloak_configured?is defined in bothApplicationController(line 37, checks onlyKEYCLOAK_URL) andSessionsController(line 48, checksKEYCLOAK_URLandKEYCLOAK_REALM). The two definitions have different logic -- the ApplicationController version is less strict. The SessionsController private method shadows the inherited one within that controller, which is intentional but could cause confusion if a future contributor expects consistent behavior. Consider removing the SessionsController override and using the inherited one, or aligning the checks.2.
mock_keycloak_authdefined as a global method (nit)In
spec/support/omniauth.rb,mock_keycloak_authis defined as a top-level method rather than inside an RSpec helper module. This works but pollutes the global namespace. A minor style point -- not blocking.What looks good
post_logout_redirect_uriandclient_idauthenticate_user!exists but is not called in abefore_actionbutton_towithmethod: :postfor login link respects CSRF protectiondata: { turbo: false }on login button prevents Turbo from intercepting the OAuth redirectkeycloak_configured?exposed as view helper so the auth bar only renders when relevantVERDICT: APPROVE
Two minor style nits, no functional issues. The implementation correctly handles the OmniAuth OIDC flow, session management, and graceful degradation when Keycloak is not configured.
PR #134 Review
DOMAIN REVIEW
Stack: Ruby on Rails 8.1.3 / OmniAuth OpenID Connect / Keycloak / RSpec request specs / Plain CSS
This PR adds Keycloak SSO via OmniAuth with Authorization Code + PKCE flow. The implementation is well-structured: session-based auth, graceful degradation when Keycloak is unconfigured, proper CSRF protection on the login button (
button_toPOST), and clean separation between the OmniAuth initializer, SessionsController, and ApplicationController helpers.OmniAuth initializer (
config/initializers/omniauth.rb):KEYCLOAK_*env vars are present OR in test mode.SessionsController:
extra.raw_info.realm_access.rolesis correct for Keycloak ID tokens.ApplicationController helpers:
current_userreturns awith_indifferent_accesshash from session -- correct pattern.authenticate_user!is defined but not enforced globally -- intentional and documented.current_user_has_role?casts to string for comparison -- safe.Layout (
application.html.erb):keycloak_configured?-- correct.button_towithmethod: :postanddata: { turbo: false }-- CSRF-safe, Turbo-safe.button_towithmethod: :delete-- correct.CSS:
--color-*,--spacing-*,--radius). Consistent with existing component patterns.BLOCKERS
1. DRY violation in auth path:
keycloak_configured?defined twice with divergent logicApplicationController#keycloak_configured?checks onlyENV["KEYCLOAK_URL"].present?.SessionsController#keycloak_configured?checksENV["KEYCLOAK_URL"].present? && ENV["KEYCLOAK_REALM"].present?.The child version silently shadows the parent. This is a DRY violation in a security-adjacent path. The view uses the ApplicationController version (checks only URL), but the logout redirect depends on
KEYCLOAK_REALMbeing present. IfKEYCLOAK_URLis set butKEYCLOAK_REALMis not, the auth bar renders (login button visible) but the initializer won't load the middleware (it requires all four vars), so clicking Login would hit a routing error.Fix: Remove the private
keycloak_configured?fromSessionsController. The parent's version is the one that should be canonical. If you want the stricter four-var check, move it to ApplicationController and use it everywhere.2.
OmniAuth.config.allowed_request_methodsincludes:get-- CSRF risk on request phaseLine 36 of
omniauth.rb:OmniAuth.config.allowed_request_methods = %i[post get]This is a global setting. While the callback legitimately receives GET from Keycloak, adding
:gettoallowed_request_methodsalso means the request phase (/auth/keycloak) can be initiated via GET without a CSRF token. Theomniauth-rails_csrf_protectiongem specifically exists to block GET-initiated request phases to prevent CSRF login attacks (an attacker can craft a link that logs the victim into the attacker's account).The callback route is handled by your Rails router (
get "/auth/keycloak/callback") and does not go through OmniAuth's request phase, so you do NOT need:getinallowed_request_methods.Fix: Remove
:getfromallowed_request_methodsand keep only%i[post]:The GET callback works because your Rails routes explicitly map
GET /auth/keycloak/callbacktosessions#create-- OmniAuth'sallowed_request_methodsonly governs the request phase (/auth/keycloak), not the callback.NITS
1. Logout URL missing
KEYCLOAK_CLIENT_IDpresence checkSessionsController#destroyguards onkeycloak_configured?(which checks URL + REALM in the controller's version), then builds the logout URL usingENV["KEYCLOAK_CLIENT_ID"]. IfCLIENT_IDis somehow nil, the URL would containclient_id=with an empty value. This is unlikely in practice (the initializer requires all four vars), but the guard and the usage are checking different subsets of env vars. Consolidating to a single four-var check in ApplicationController would close this gap.2. Missing test for
authenticate_user!redirectThe
authenticate_user!before_action is defined but no spec verifies it. When a future controller addsbefore_action :authenticate_user!, there should be a spec showing that unauthenticated users get redirected to/auth/keycloak. Consider adding a spec now even if no controller uses it yet.3. Missing test for
current_user_has_role?No spec exercises the role-checking helper. A quick unit-level test (or request spec that uses a controller leveraging this method) would be valuable.
4. Missing test for nil nickname fallback
SessionsController#createline 15:auth.info.nickname || auth.info.name. No spec covers the case wherenicknameis nil andnameis used instead. This is a real possibility depending on Keycloak user configuration.5. Session roles frozen at login time
Roles stored in the session at login are not refreshed until the user logs out and back in. If a Keycloak admin changes a user's roles, the app won't reflect it until re-authentication. This is standard for session-based auth, but worth documenting (perhaps a comment in
current_user_has_role?).6.
params[:message]in failure flashSessionsController#failureinterpolatesparams[:message]into a flash string. Rails ERB auto-escapes this, so it is not an XSS vector. However, an attacker could craft a URL like/auth/failure?message=anything+they+wantand the user would see "Login failed: anything they want" in the flash. Consider allowlisting known OmniAuth failure messages or at least truncating to a reasonable length.7.
OmniAuth.config.silence_get_warningin test supportspec/support/omniauth.rbline 2 setssilence_get_warning = true. This is fine for test mode but worth confirming this does not mask the GET request phase concern mentioned in Blocker #2.SOP COMPLIANCE
115-keycloak-omniauth-loginfollows{issue-number}-{kebab-case-purpose}Closes #115).env*in.gitignore, no hardcoded credentialsPROCESS OBSERVATIONS
docs/keycloak-auth.mdin a follow-up for operational runbook (env var requirements, Keycloak realm setup, role mapping).VERDICT: NOT APPROVED
Two blockers must be addressed:
keycloak_configured?fromSessionsController-- use the inherited version fromApplicationController(and consider making the ApplicationController version check all four vars).:getfromOmniAuth.config.allowed_request_methods-- keep only%i[post]. The GET callback works through Rails routing, not OmniAuth's request phase handler.QA Review Fixes —
c4dae6dBoth blockers from QA review addressed:
Removed duplicate
keycloak_configured?fromSessionsController— the parent method inApplicationControlleris sufficient. The child was silently shadowing with stricter logic (checkingKEYCLOAK_REALMin addition toKEYCLOAK_URL), creating inconsistency between view rendering and logout routing.Removed
:getfromOmniAuth.config.allowed_request_methods— having GET in the allowed methods meant the OmniAuth request phase (/auth/keycloak) could be triggered via GET without a CSRF token, defeatingomniauth-rails_csrf_protection. The GET callback works because Rails routes explicitly mapGET /auth/keycloak/callbacktosessions#create, not because of this OmniAuth setting.Ready for re-review.
PR #134 Re-Review
Re-review after two blocker fixes pushed in commits
c4dae6dandff782e4.PREVIOUS BLOCKERS -- STATUS
1. DRY violation: duplicate
keycloak_configured?-- RESOLVEDSessionsControllerno longer defines its ownkeycloak_configured?. The method is defined once inApplicationController(line 36 of the diff), andSessionsController#destroycorrectly inherits it. No divergent logic.2. CSRF risk:
:getinallowed_request_methods-- RESOLVEDconfig/initializers/omniauth.rbnow setsOmniAuth.config.allowed_request_methods = %i[post](POST only). The view usesbutton_towithmethod: :postanddata: { turbo: false }, which generates a proper form with a CSRF token. Theomniauth-rails_csrf_protectiongem provides additional CSRF verification on the request phase. Solid.DOMAIN REVIEW
Tech stack: Ruby on Rails 8.1, OmniAuth OpenID Connect, RSpec, plain CSS.
OmniAuth / Auth patterns:
response_type: :code,pkce: true)KEYCLOAK_CLIENT_SECRET-- appropriate for server-side Rails appauthenticate_user!is defined but NOT enforced globally viabefore_action-- intentional per PR description, correct for Phase 1auth.dig("extra", "raw_info")with nil-safe fallback (|| {},|| [])KEYCLOAK_URLpresence -- app works without auth when env var is unsetpost_logout_redirect_uriandclient_id-- correct OIDC RP-initiated logoutInitializer guard logic:
keycloak_configuredchecks all four env vars (URL, REALM, CLIENT_ID, CLIENT_SECRET)discovery: keycloak_configuredskips OIDC discovery in test mode (no real IdP) -- correctCSS:
--color-surface,--color-border,--color-accent,--spacing-sm,--radius, etc.)font-size: 0.85remand0.8remare reasonable for a utility bar, not magic numbers in this contextTest coverage (7 specs):
mock_keycloak_auth) is reusable with keyword args for different user profileselsebranch indestroy). This is a gap but not a blocker since the logic is trivial (redirect with notice).View (application.html.erb):
button_togenerates a form with CSRF token -- correct for POST-only OmniAuthdata: { turbo: false }prevents Turbo from intercepting the redirect to Keycloak -- correctkeycloak_configured?-- no auth UI leaks without KeycloakRoutes:
/auth/keycloak/callback-- GET is needed for the OmniAuth redirect after the IdP callback, POST is the standard OmniAuth callback path. Correct./logoutfollows RESTful convention/auth/failurecaptures OmniAuth errorsDocs (commit
ff782e4):keycloak-setup.mdcross-repo table updated from "New ticket" to specific PR numbers with "DONE" status -- good traceabilityuser-stories-auth.mdcorrected from "four test accounts" to "five test accounts"BLOCKERS
None. Both previous blockers are resolved. No new blockers found.
NITS
Missing test for logout without Keycloak -- The
elsebranch inSessionsController#destroy(redirect toroot_pathwith notice "Signed out") has no spec coverage. Consider adding a test whereKEYCLOAK_URLis nil to cover both branches.CGI.escapein logout URL --CGI.escape(root_url)andCGI.escape(ENV["KEYCLOAK_CLIENT_ID"])are correct, but consider extracting the logout URL construction into a private method for readability, especially if id_token_hint is added later for full RP-initiated logout compliance.Blank line before
endin SessionsController -- Line 45 in the new file has a blank line before the finalend. Minor style nit.OmniAuth.config.silence_get_warning = truein test support -- This silences warnings about GET requests in test mode, which is fine, but worth a comment explaining why (OmniAuth test_mode uses GET internally for redirects even when POST is enforced).SOP COMPLIANCE
115-keycloak-omniauth-loginmatches issue #115)ENV.fetchwith test-only defaults)PROCESS OBSERVATIONS
keycloak-setup.mdwith specific PR numbers -- excellent traceability for a multi-repo change.VERDICT: APPROVED