Skip to content

Required actions

A required action is something the realm insists a user does before the login completes. The package ships three — confirm your email address, replace an expired password, enroll a second factor — and your application can add its own.

Pendency is derived, never stored. An action reads the state that makes it necessary and stops reporting once that state changes. There is no row to go stale, and a realm that turns a rule on sees it apply to users who registered long before it existed.

Nothing calls guard->login() until the realm has nothing left to ask for. A login with an open action returns LoginOutcome::RequiredAction, and no session exists — the same shape the multi-factor hand-off already had.

flowchart TD
    A["Primary factor verified"] --> B["Post-login pipeline"]
    B -- "deny()" --> F["Generic auth.failed error"]
    B -- ok --> C{"Second factor?"}
    C -- yes --> D["Two-factor challenge"]
    C -- no --> E
    D --> E{"Anything the realm requires?"}
    E -- yes --> G["Action screen"]
    G --> E
    E -- no --> H["Log in + regenerate session"]

A browser request is redirected to the first open action’s screen; a JSON request receives {"required_actions": ["update_password"]}. Each screen hands back to the same check when it is done, so several open actions are walked through in order.

The screens are not behind the identity guard

Section titled “The screens are not behind the identity guard”

They cannot be: mid-login there is no session yet, by design. They run behind RequireActionSubject instead, which accepts either a session or a pending login and resolves the user from whichever is there.

Route name Path Action
identity.verification.notice auth/email/verify verify_email
identity.verification.verify auth/email/verify/{id}/{hash} verify_email
identity.verification.send auth/email/verification-notification verify_email
identity.password.change auth/user/password update_password
identity.password.change.store auth/user/password update_password
identity.two-factor.setup auth/user/two-factor/setup configure_mfa
identity.two-factor.setup.continue auth/user/two-factor/setup/continue configure_mfa

A session established before the realm’s rules caught up with it — a password that aged out, a requirement switched on — never passed the login-time check. So /oauth/authorize asks again before it issues a code or shows consent:

  • with prompt=none, the request fails with interaction_required (OIDC Core §3.1.2.6)
  • otherwise the user is sent to the action’s screen and returned to the authorization request afterwards

This is what makes a required action a guarantee about every token this provider issues, rather than a detail of one login screen.

Key Pending when Configured by
verify_email the user’s address is unconfirmed oidc.authentication.email_verification_required
update_password the password is older than the rotation window oidc.password_policy.max_age_days
configure_mfa the user has no factor that can be challenged oidc.authentication.mfa = always

There is deliberately no separate list of enabled actions: each one’s trigger is already a realm setting, and a second switch would only be a way for the two to disagree. All three read AuthenticationSettings, so they can differ per realm.

verify_email only applies to a user model implementing MustVerifyEmail, and update_password only to one implementing CanResetPassword — an action that could never be settled is never raised.

Implement RequiredAction and append it to the registry. isPending() reads whatever state you keep — an unaccepted terms version, an incomplete profile — which is why the package needs no table for it.

use Bambamboole\LaravelOidc\Server\Shared\Authentication\RequiredAction;
use Bambamboole\LaravelOidc\Server\Shared\Authentication\RequiredActionRegistry;
use Bambamboole\LaravelOidc\Server\Shared\Realms\Realm;
use Illuminate\Contracts\Auth\Authenticatable;
final readonly class AcceptTermsAction implements RequiredAction
{
public function key(): string
{
return 'accept_terms';
}
public function isPending(Authenticatable $user, Realm $realm): bool
{
return $user->accepted_terms_version < $realm->identifier();
}
public function route(): string
{
return 'terms.show';
}
}
// In a service provider's boot():
$this->app->make(RequiredActionRegistry::class)->register(new AcceptTermsAction);

Registration order is the order the user is walked through open actions.

Your screen must put the user behind RequireActionSubject and finish by handing back to the login sequence:

use Bambamboole\LaravelOidc\Server\Shared\Authentication\ContinuesLogin;
class TermsController
{
use ContinuesLogin;
public function store(Request $request)
{
// ... record the acceptance ...
return $this->continueAfterAction($request, $user, 'accept_terms');
}
}

isPending() runs on every login and every authorization request, so keep it cheap. It must not assume a session exists; if it throws, the login is denied, because a user who never reaches the end of the ceremony never gets a session.

One-off actions from the post-login pipeline

Section titled “One-off actions from the post-login pipeline”

A decision that applies to one login rather than to the account belongs in the post-login pipeline:

$api->requireAction('update_password');

The demand lives on the session for that login only. An unregistered key is dropped and logged rather than parking the user on a screen that does not exist.

There is no per-user table of assigned actions, so an administrator cannot flag one user for a password change the way Keycloak’s admin console does. Everything is derived from state. If you need that, keep the flag on your own model and register an action that reads it — which is the same extension point as any other.