Step-Up Authentication (Sudo Mode)

StepUpAuthenticationAspect requires that a request's credential was proven recently — and optionally by a specific method — before a sensitive action is allowed to run. It's Canvas's equivalent of GitHub's "sudo mode": being logged in only proves who you are, not when you last proved it.

explanation

The Problem

A session or JWT that's hours old is still "valid" right up until it expires — fine for browsing, but not for destructive or high-value actions like deleting an account, changing an email, or viewing billing details. Anyone who gets hold of that credential for the rest of its lifetime can perform those actions too.

StepUpAuthenticationAspect closes that gap by checking, in addition to normal authentication, how long ago the credential was actually proven — and, optionally, whether it was proven by a strong enough method. If either check fails, the action is denied even though the request is otherwise authenticated.

Quick Start

Stack it on top of whatever already authenticates the request, on just the sensitive method:

<?php
namespace App\Controllers;

use Quellabs\Canvas\Annotations\Route;
use Quellabs\Canvas\Annotations\InterceptWith;
use Quellabs\Canvas\Security\StepUpAuthenticationAspect;

class AccountController extends App\Controllers\AuthenticatedController {

    /**
     * @Route("/account/delete", methods={"POST"})
     * @InterceptWith(StepUpAuthenticationAspect::class, maxAge=300, requiredMethods={"pwd"})
     */
    public function deleteAccount(): Response {
        // Only reached if the credential was proven within the last 5 minutes via 'pwd'
    }
}

AuthenticatedController (scaffolded by canvas-authorization's install:auth) already applies session and account-eligibility checks to every action. This one extra annotation adds a stricter, method-specific gate on top for a single sensitive action — sudo-mode is deliberately opt-in per action, not bundled by default.

Auth-Mechanism Agnostic

StepUpAuthenticationAspect doesn't know or care whether the underlying authenticator is JWT- or session-based. It only reads two request attributes that an earlier @InterceptWith aspect in the chain is expected to set:

Attribute Type Meaning
auth_time int|float Unix timestamp of when the credential was last proven
auth_methods string[] Methods used to obtain the credential (RFC 8176 names recommended, e.g. pwd, hwk, otp; empty when unknown)

Two authenticators in the Canvas ecosystem currently produce both attributes:

  • JwtAuthenticationAspect (Canvas core) — sets auth_time from the token's iat claim and auth_methods from its amr claim, both optional per their respective RFCs.
  • SessionAuthenticationAspect (quellabs/canvas-authorization) — sets both directly from the auth_time / auth_methods session keys, which canvas-authorization's scaffolded login writes on successful credential verification. See the Authentication page.

StepUpAuthenticationAspect itself has no idea which of these ran — or whether a third, custom authenticator did.

Configuration

StepUpAuthenticationAspect has no config file — like RateLimitAspect, it's configured entirely through annotation parameters:

Parameter Type Default Meaning
maxAge int 300 Required freshness window in seconds. Must be > 0.
requiredMethods string[] [] When non-empty, at least one must be present in auth_methods.
throwOnFailure bool false Throw StepUpAuthenticationException instead of writing to request attributes.

Use two instances for a two-tier policy — a broader threshold for lower-risk actions, a tighter one for higher-risk actions:

// Viewing billing history: logged in within the last 2 hours is enough
/** @InterceptWith(Quellabs\Canvas\Security\StepUpAuthenticationAspect::class, maxAge=7200) */

// Deleting the account: must have logged in within the last 5 minutes
/** @InterceptWith(Quellabs\Canvas\Security\StepUpAuthenticationAspect::class, maxAge=300) */

Failure Modes

Attribute mode (default) Exception mode (throwOnFailure=true)
On failure Sets recent_auth_error on request attributes; controller still executes Throws StepUpAuthenticationException; controller never executes
Error response Controller decides format and status code Handled by your registered ErrorHandlerInterface
Best for Rendering a "confirm your password" form inline APIs returning a uniform 403 for a client to react to

Like every other authentication aspect in Canvas, StepUpAuthenticationAspect never builds a Response itself — not even a redirect. There is no automatic "resume the original action" flow either: how you re-prompt for sudo (a confirm-password modal, a redirect to a form, a 403 an SPA reacts to) is specific to the action, not something the framework assumes.

Exception Mode

StepUpAuthenticationException (HTTP 403) carries enough context for a handler to build a machine-readable "step-up required" response:

<?php
namespace App\Errors;

use Quellabs\Canvas\Error\ErrorHandlerInterface;
use Quellabs\Canvas\Exceptions\StepUpAuthenticationException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class StepUpErrorHandler implements ErrorHandlerInterface {

    public static function supports(\Throwable $e): bool {
        return $e instanceof StepUpAuthenticationException;
    }

    public function handle(\Throwable $e, Request $request): Response {
        return new JsonResponse([
            'error'            => 'step_up_required',
            'max_age'          => $e->getMaxAge(),
            'required_methods' => $e->getRequiredMethods(),
            'presented_methods'=> $e->getPresentedMethods(),
            'retry_method'     => $e->getRequestMethod(),
            'retry_path'       => $e->getRequestPath(),
        ], 403);
    }
}

The client's "resume" is simply retrying the identical request — retry_method/retry_path — once it satisfies max_age and required_methods.

Requiring a Specific Method

A stolen remember-me cookie shouldn't count as "recent" proof of a credential. Canvas has no built-in remember-me concept, but the same risk exists if your application reissues credentials on silent refresh: a refreshed token or session can look "fresh" by auth_time alone even though the user hasn't entered a credential in days.

requiredMethods is how you close that gap. Tag credentials issued via a weak flow with a method your issuer controls (e.g. refresh), and credentials issued via an interactive login with a strong one (e.g. pwd, hwk, mfa), then require the strong one for sensitive actions:

/** @InterceptWith(Quellabs\Canvas\Security\StepUpAuthenticationAspect::class, maxAge=300, requiredMethods={"pwd"}) */

A token or session with auth_methods = ['refresh'] fails this check regardless of how recent auth_time is; one with auth_methods = ['pwd'] (or ['pwd', 'hwk']) passes. There is no default value for requiredMethods — Canvas doesn't issue or refresh credentials itself, so it has no basis for guessing which of your application's methods are "weak." That decision, and tagging amr (JWT) or auth_methods (session) correctly at issuance time, is entirely on your login/token-issuing code.

Design Choices

  • Weak-method exclusion is opt-in, not automatic. Canvas has no built-in opinion on what counts as a weak authentication method. If you don't set requiredMethods (or don't tag auth_methods at issuance), a credential refreshed indefinitely without a real login will always read as "recently authenticated" — that's a decision left to your application, not a gap in the check.
  • Freshness is tracked per credential, not per method. auth_methods is a flat list with no per-method timing — the single auth_time applies to the whole credential, not to each method individually.
  • No resume-after-reauth flow, by design. Canvas is largely stateless — even session-based auth here carries no server-side "pending action" concept — so the reauth UX is deliberately left to the client, rather than the framework guessing what "resume" should mean for a given action.
  • Trust is delegated to the issuer, not re-verified. auth_time and auth_methods are only as trustworthy as whatever code sets iat/amr when issuing a JWT, or auth_time/auth_methods when writing a session at login. Canvas checks freshness and method membership, not whether a pwd claim actually followed a real password check — that's the issuer's responsibility.
  • Aspect ordering is the developer's responsibility. If StepUpAuthenticationAspect is placed before the authenticator in @InterceptWith (or no authenticator runs at all), auth_time will be absent and the check simply fails closed — safe, but the failure reason won't be obvious without checking aspect order. Canvas doesn't infer or enforce a required order between aspects.

Best Practices

  • Reserve it for genuinely sensitive actions: account deletion, email/password changes, payment method changes, viewing sensitive financial or personal data — not ordinary page views.
  • Place it after the base authenticator: @InterceptWith annotations execute in declaration order, so StepUpAuthenticationAspect must come after whatever sets auth_time/auth_methods.
  • Set requiredMethods once you have a weaker issuance path: if your application ever adds a refresh or remember-me flow, tag it distinctly and require the strong method on sensitive actions from day one.
  • Prefer exception mode for APIs: a uniform StepUpAuthenticationException handler keeps step-up logic out of every controller, the same way throw_on_failure works for JwtAuthenticationAspect.
  • Use two tiers sparingly: most applications need only one maxAge threshold for their handful of sensitive actions; reserve a second, shorter tier for the truly destructive ones.