MFA hardening: OAuth2 memento guard on recovery, DTO refactors, full OIDC circuit tests - #152
Conversation
…code verify2FARecovery() skipped the resolveClientFromMemento() guard that verify2FA() applies, so with a pending OAuth2 authorization request whose client no longer exists the single-use recovery code was burned and an IDP session established for an authorization request that could only fail at the /oauth2/auth hop. Apply the same guard before redemption; recovery-code checking itself stays client-agnostic.
The error_code values emitted by UserController's MFA endpoints were hardcoded strings, duplicated in TwoFactorRateLimitMiddleware::FAILURE_CODES where a silent drift would break the rate-limit failure counting. Tests keep asserting the literal wire values on purpose, pinning the contract.
…n array MFAPendingState (getUserId / getPendingAt / shouldRemember) replaces the string-keyed array, so callers stop scattering 'user_id'/'remember' literals and casts, and the shape is enforced by the type system instead of by convention.
…tatus DTO verify2FARecovery() and getProfile() each hand-built the recovery_codes_remaining/total/low_threshold payload with their own config() reads and magic defaults. IRecoveryCodeService::getStatus() now returns a RecoveryCodesStatus DTO whose toArray() owns the wire keys, so both call sites merge the same serialized shape. Side effect: the recovery XHR response now also carries recovery_codes_total (additive, ignored by the SPA).
…fy2FARecovery authorize -> login -> MFA challenge -> verify (OTP / recovery code) -> redirect_url back to the authorization endpoint (rebuilt from the session memento) -> consent screen -> AllowOnce -> authorization code delivered to the client redirect_uri. Locks in that the XHR verify contract composes with the interactive grant's memento round-trip. Note: OIDCProtocolTestCase's password-login circuits (e.g. testAuthCode) predate the MFA gate and post a wrong seed password - broken independently of this change.
…d + MFA gate) Two stacked breakages, both predating and unrelated to each individual test: - 021bee3 (jul 2024) changed the TestSeeder passwords from '1qaz2wsx' to '1Qaz2wsx!' without updating this class, so every password login leg has silently failed since - errorLogin() also answers 302, so the post-login assertion kept passing and tests died downstream instead. - The MFA gate now challenges the seeded login user (SuperAdminGroup is in two_factor.enforced_groups), so even a correct password stops at the 2FA challenge. This class exercises the OIDC protocol, not the gate - enforced groups are cleared in prepareForTests(); the gate plus the full authorize -> MFA -> consent -> code circuit live in TwoFactorLoginFlowTest. Result: 29 broken -> 3 (32/35 green). The 3 residuals have distinct pre-existing causes: testConsentLogin and testGetRefreshTokenWithPromptSetToConsentLogin lose the login hint because AuthService::logout()'s Session::flush() (4864f50 / #118) wipes the session-backed security context even when called with clear_security_ctx = false (prompt=login path); testTokenResponseModePost uses max_age=1 and the multi-request dance now takes longer than 1s, forcing a re-login.
…lush The Session::flush() hardening added in #118 wipes the whole session at the end of logout(), including the session-backed security context - even when the caller passed clear_security_ctx = false (the prompt=login re-authentication path in InteractiveGrantType::mustAuthenticateUser()), which broke the login-hint prefill on the login screen for prompt=login OIDC requests. Capture the context before the flush and re-save it after the session ID regenerate; everything else is still flushed, so the #118 hardening stands.
The test exercises response_mode=form_post, not max_age expiry (testMaxAge1AndWait2 owns that) - with max_age=1 the multi-request login+consent dance takes longer than 1s and the final authorize hop forced a re-login instead of delivering the form post. 3200 matches the sibling circuits. OIDCProtocolTestCase is now fully green: 35/35.
📝 WalkthroughWalkthroughThe PR introduces typed MFA pending state, centralized MFA error codes, recovery-code status responses, OAuth2 client validation, logout security-context preservation, and expanded MFA/OIDC test coverage. ChangesMFA authentication and recovery flow
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant UserController
participant MFAChallengeStrategy
participant OAuth2Client
participant RecoveryCodeService
UserController->>MFAChallengeStrategy: Read pending MFA state
UserController->>OAuth2Client: Validate pending OAuth2 client
UserController->>RecoveryCodeService: Redeem recovery code
RecoveryCodeService-->>UserController: Return recovery status
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 PHPStan (2.2.7)Composer install failed: the CodeRabbit sandbox could not download one or more dependencies. Instead, run PHPStan in a CI/CD pipeline where you can use custom packages — our pipeline remediation tool can use the PHPStan output from your CI/CD pipeline. 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. Comment |
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-152/ This page is automatically updated on each push to this PR. |
…overy Six tests inside a pending OIDC authorization-code flow, three per endpoint: - wrong code then correct code: the rejection keeps the pending challenge and the OAuth2 memento alive, and the retry completes the full circuit (consent -> authorization code). - consecutive wrong codes up to the rate-limit threshold: every attempt is 401 without a session, and once the window closes even the CORRECT code answers 429 - brute-forcing inside a pending flow buys no extra attempts. - burned single-use code (used recovery code / redeemed OTP): rejected like any invalid code, and the flow still completes afterwards with a fresh code (new recovery code / resent OTP).
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-152/ This page is automatically updated on each push to this PR. |
- validator 412s (malformed request, no otp_value / recovery_code) - vanished pending user -> mfa_session_expired + pending state cleared - recovery without a pending challenge -> mfa_session_expired - stale OAuth2 client guard on verify2FA (parity with the recovery test): 412 before the OTP is redeemed - audit failure on the FAILED-verify path stays a clean 401 with the error_code the rate-limit middleware keys on, for both endpoints verify2FA line coverage 82.3% -> 95.2%, verify2FARecovery 82.7% -> 94.2%; the only uncovered lines left are the generic Exception -> 500 catches.
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-152/ This page is automatically updated on each push to this PR. |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
tests/OIDCProtocolTestCase.php (1)
135-135: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider extracting the seeded password into a class constant.
The literal
1Qaz2wsx!now appears at about 26 call sites in this file. This PR had to edit every one of them. A private constant, asTwoFactorLoginFlowTest::SEED_PASSWORDalready does, reduces the next seed change to one edit. Keep the trailing-space form at this line explicit, because that spacing is the subject under test.♻️ Proposed refactor
Add the constant near the top of the class:
final class OIDCProtocolTestCase extends OpenStackIDBaseTestCase { private const SEED_PASSWORD = '1Qaz2wsx!';Then replace the literals:
'username' => ' sebastian@tipit.net ', - 'password' => ' 1Qaz2wsx! ', + 'password' => ' ' . self::SEED_PASSWORD . ' ','username' => 'sebastian@tipit.net', - 'password' => '1Qaz2wsx!', + 'password' => self::SEED_PASSWORD,🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/OIDCProtocolTestCase.php` at line 135, Extract the repeated seeded password into a private OIDCProtocolTestCase::SEED_PASSWORD class constant and replace the other exact password literals with that constant. Keep the password value with trailing spaces explicit at the shown password-field call site, since that test must continue verifying whitespace handling.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app/libs/Auth/MFAConstants.php`:
- Around line 29-31: Add ERROR_CODE_VERIFICATION_FAILED and
ERROR_CODE_INVALID_RECOVERY to the MFA_ERROR_CODE definition, alongside the
existing ERROR_CODE_SESSION_EXPIRED entry, so all three MFA error codes are
exposed to the login SPA.
---
Nitpick comments:
In `@tests/OIDCProtocolTestCase.php`:
- Line 135: Extract the repeated seeded password into a private
OIDCProtocolTestCase::SEED_PASSWORD class constant and replace the other exact
password literals with that constant. Keep the password value with trailing
spaces explicit at the shown password-field call site, since that test must
continue verifying whitespace handling.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 95e6cae3-bc85-47e5-befa-d941108e62e7
📒 Files selected for processing (14)
app/Http/Controllers/UserController.phpapp/Http/Middleware/TwoFactorRateLimitMiddleware.phpapp/Services/Auth/IRecoveryCodeService.phpapp/Services/Auth/RecoveryCodeService.phpapp/Services/Auth/RecoveryCodesStatus.phpapp/Strategies/MFA/AbstractMFAChallengeStrategy.phpapp/Strategies/MFA/IMFAChallengeStrategy.phpapp/Strategies/MFA/MFAPendingState.phpapp/libs/Auth/AuthService.phpapp/libs/Auth/MFAConstants.phptests/OIDCProtocolTestCase.phptests/TwoFactorLoginFlowTest.phptests/unit/AuthServiceLogoutTest.phptests/unit/MFA/AbstractMFAChallengeStrategyTest.php
…ure breadth Two changes to phpunit.xml: - The Application suite's <directory> scan only picks up *Test.php (PHPUnit's default suffix), so the four concrete *TestCase.php protocol suites (OAuth2Protocol, OIDCProtocol, OIDCPasswordless, OpenIdProtocol - 93 tests) were NEVER executed by CI. That is how OIDCProtocolTestCase stayed broken for two years with green builds. They are now listed explicitly. - stopOnFailure=false so a run reports every failure instead of dying on the first one. Also fixes the one test the newly-wired suites surfaced: testResourceServerIntrospectionNotValidIP expected an unconditional 400, but the resource-server IP check became opt-in in #98 (oauth2.validate_resource_server_ip, default off) - the test now enables the flag before asserting the rejection. Full-suite evidence (523 tests): green except 8 pre-existing environment-dependent Turnstile tests that need TEST_USER_EMAIL / TEST_USER_PASSWORD and the Turnstile secrets CI injects (they pass in CI; locally their markTestSkipped guard is defeated by a typed-property TypeError when the env vars are absent).
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-152/ This page is automatically updated on each push to this PR. |
MFAConstants now owns all of them:
- error codes: the existing three plus mfa_rate_limit and mfa_required.
ITwoFactorRateLimitService::RATE_LIMIT_ERROR_CODE and
ILoginStrategy::MFA_REQUIRED alias it, so consumers keep their names while
the value is defined once.
- 2fa_* session keys: previously defined TWICE in production
(AbstractMFAChallengeStrategy's private consts and
ITwoFactorRateLimitService::PENDING_USER_SESSION_KEY) - both now alias
MFAConstants.
Also promotes the rate-limit cache-key prefix ('2fa_rate:', previously a
sprintf literal in TwoFactorRateLimitService duplicated by the test flush
helper) to ITwoFactorRateLimitService::RATE_LIMIT_CACHE_KEY_PREFIX.
All ~50 hardcoded literals across TwoFactorLoginFlowTest,
AbstractMFAChallengeStrategyTest and EmailOTPMFAChallengeStrategyTest now
reference the constants.
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-152/ This page is automatically updated on each push to this PR. |
The literal appeared at 26 call sites; a seed password change is now a one-line edit, matching TwoFactorLoginFlowTest. The trailing-space login test keeps its spacing explicit around the constant, since that spacing is the subject under test. Suite re-run in idp-app: 35/35, 506 assertions.
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-152/ This page is automatically updated on each push to this PR. |
Code reviewNo issues found. Checked for bugs and CLAUDE.md compliance. 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
ec64f2f
into
feat/mfa-phase1---migrations--and--interfaces
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
tests/TwoFactorLoginFlowTest.php (1)
79-80: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winUse the rate-limit action constants in test cleanup.
The loop hardcodes
'verify','recovery', and'resend', whileITwoFactorRateLimitServicealready definesActionVerify,ActionRecovery, andActionResend. If an action value changes, teardown can leave stale counters and make tests order-dependent.Suggested fix
- foreach (['verify', 'recovery', 'resend'] as $action) { + foreach ([ + ITwoFactorRateLimitService::ActionVerify, + ITwoFactorRateLimitService::ActionRecovery, + ITwoFactorRateLimitService::ActionResend, + ] as $action) {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/TwoFactorLoginFlowTest.php` around lines 79 - 80, Update the cleanup loop in TwoFactorLoginFlowTest to use ITwoFactorRateLimitService::ActionVerify, ActionRecovery, and ActionResend instead of hardcoded action strings, preserving the existing cache-key construction and forget behavior.tests/OAuth2ProtocolTestCase.php (1)
462-467: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low valueCorrect the explanation of
testValidateToken().
testValidateToken()performs introspection with the regular OAuth client. Resource server 1 is used only bytestResourceServerIntrospection().🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/OAuth2ProtocolTestCase.php` around lines 462 - 467, Correct the comment above Config::set in testValidateToken() so it accurately states that testValidateToken() introspects with the regular OAuth client, while resource server 1 is used only by testResourceServerIntrospection(). Preserve the explanation that the IP-validation flag must be enabled for the rejection test and set after testValidateToken().
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@tests/OAuth2ProtocolTestCase.php`:
- Around line 465-466: Update the comment associated with testValidateToken() to
identify the main OAuth2 client defined at the earlier client setup, and state
that its registered IPs include the test-request IP. Remove the incorrect
reference to resource.server.1, which belongs to
testResourceServerIntrospection().
---
Nitpick comments:
In `@tests/OAuth2ProtocolTestCase.php`:
- Around line 462-467: Correct the comment above Config::set in
testValidateToken() so it accurately states that testValidateToken() introspects
with the regular OAuth client, while resource server 1 is used only by
testResourceServerIntrospection(). Preserve the explanation that the
IP-validation flag must be enabled for the rejection test and set after
testValidateToken().
In `@tests/TwoFactorLoginFlowTest.php`:
- Around line 79-80: Update the cleanup loop in TwoFactorLoginFlowTest to use
ITwoFactorRateLimitService::ActionVerify, ActionRecovery, and ActionResend
instead of hardcoded action strings, preserving the existing cache-key
construction and forget behavior.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 19de687d-05c4-4e6f-80f4-efc14eea7fd0
📒 Files selected for processing (11)
app/Services/Auth/ITwoFactorRateLimitService.phpapp/Services/Auth/TwoFactorRateLimitService.phpapp/Strategies/ILoginStrategy.phpapp/Strategies/MFA/AbstractMFAChallengeStrategy.phpapp/libs/Auth/MFAConstants.phpphpunit.xmltests/OAuth2ProtocolTestCase.phptests/OIDCProtocolTestCase.phptests/TwoFactorLoginFlowTest.phptests/unit/MFA/AbstractMFAChallengeStrategyTest.phptests/unit/MFA/EmailOTPMFAChallengeStrategyTest.php
🚧 Files skipped from review as they are similar to previous changes (3)
- tests/unit/MFA/AbstractMFAChallengeStrategyTest.php
- app/Strategies/MFA/AbstractMFAChallengeStrategy.php
- tests/OIDCProtocolTestCase.php
| // testValidateToken(): that helper introspects from resource server 1, | ||
| // whose registered IPs do include the test-request IP. |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Correct the resource-server client description.
testValidateToken() uses the main OAuth2 client defined at Lines 343-344. It does not introspect from resource.server.1; that client is used by testResourceServerIntrospection(). Update the comment to describe the actual client setup.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@tests/OAuth2ProtocolTestCase.php` around lines 465 - 466, Update the comment
associated with testValidateToken() to identify the main OAuth2 client defined
at the earlier client setup, and state that its registered IPs include the
test-request IP. Remove the incorrect reference to resource.server.1, which
belongs to testResourceServerIntrospection().
Summary
Hardening and cleanup on top of the 2FA feature (#126), plus repairs to the OIDC protocol test suite.
Fixes
resolveClientFromMemento()guard thatverify2FA()applies, so with a pending OAuth2 authorization request whose client no longer exists, the single-use recovery code was burned and an IDP session established for an authorization request that could only fail at the/oauth2/authhop. Covered by a red-green regression test.clear_security_ctx = falseacross the session flush. TheSession::flush()hardening from fix(session):Added Session::flush() + Session::regenerate() at the en… #118 wiped the session-backed security context even when the caller asked to keep it (theprompt=loginre-authentication path), breaking the login-hint prefill on the login screen. The context is now captured before the flush and re-saved after the session ID regenerate; everything else is still flushed. Covered byAuthServiceLogoutTest(red-green verified).Refactors
MFAConstants: the MFAerror_codewire literals, previously duplicated betweenUserControllerandTwoFactorRateLimitMiddleware::FAILURE_CODES(where silent drift would break rate-limit failure counting).MFAPendingStateDTO:IMFAChallengeStrategy::getPendingState()returns a typed object instead of a string-keyed array.RecoveryCodesStatusDTO:IRecoveryCodeService::getStatus()owns therecovery_codes_remaining/total/low_thresholdwire shape consumed byverify2FARecovery()andgetProfile()(which each hand-built it with inlineconfig()reads). Additive contract change: the recovery XHR response now also carriesrecovery_codes_total.Tests
OIDCProtocolTestCaserepaired: 29 broken → 0 (35/35 green). Two stacked pre-existing causes: seed passwords went stale in 021bee3 (jul 2024) and every login leg silently failed since (errorLogin()also answers 302); and the MFA gate now challenges the seeded super-admin, so enforced groups are cleared in this class (the gate is covered byTwoFactorLoginFlowTest). Also raisedtestTokenResponseModePost'smax_agefrom 1 to 3200 — it testsresponse_mode=form_post, not max_age expiry, and the login+consent dance takes longer than 1s.Test evidence (run inside the idp-app container)
TwoFactorLoginFlowTest: 43/43 (237 assertions)OIDCProtocolTestCase: 35/35 (506 assertions, stop-on-failure disabled)tests/unit/: 80/80 (2 pre-existing deprecations)Summary by CodeRabbit
New Features
Bug Fixes
Tests