class

KemalIdentity::Kemal::RequestContext

Inherits Reference < Object

env.auth: what the application asks about the current request.

Built by AuthenticationHandler for every request, authenticated or not, so that env.auth is never nil and a public page can render differently for a signed-in visitor without a guard rejecting anonymous ones.

Constructors

new(env : HTTP::Server::Context, app : Application, outcome : Outcome)
Source

Instance methods

adopt_legacy_session!(subject : String) : Principal | Nil

Mints a session for an account named by the system this application is migrating off.

Returns nil — never raises — for a subject the account store does not have, or one whose account is disabled. Both are ordinary: the old system outlived a deletion, or somebody was disabled here and the old cookie has not noticed yet. A hostile value reaching this from a tampered legacy cookie ends the same way.

AssuranceLevel::Remembered, deliberately. See LegacySessionHandler for why an adopted session must not claim that somebody typed a password.

Source
anonymous?

No credential was presented at all.

Source
authenticated?
Source
authorize(permission : String, tenant : String | Nil = nil, resource : Authz::Authorizable | Nil = nil, attributes : Hash(String, String) | Nil = nil) : Authz::Decision

The decision, unraised, for a caller that wants to branch on it.

An anonymous request is Forbidden rather than an exception: asking "may they" about somebody who is not signed in has an answer, and it is no.

Source
authorize!(permission : String, tenant : String | Nil = nil, resource : Authz::Authorizable | Nil = nil, attributes : Hash(String, String) | Nil = nil) : Principal

The principal, if they may perform permission.

Two different refusals, and the difference matters to the caller:

  • nobody is signed in — NotAuthenticatedError, a 401, go and log in;
  • signed in and not allowed — ForbiddenError, a 403, logging in again will not help;
  • signed in, allowed, but not strongly enough authenticated — FreshAuthenticationRequiredError, also a 403, but the application should prompt for a second factor rather than show a dead end.
post "/invoices/:id/refund" do |env|
  env.auth.authorize!("invoices.refund", tenant: env.params.url["tenant"])
  # ...
end

Pass the tenant. A check that names no tenant is a question about global scope, not a question about whichever tenant the route happens to be operating on, so a route that forgets it gets a denial rather than a quiet upgrade — but a route that forgets it and the caller holds a global role gets an answer about the wrong thing entirely.

The denial is logged with its reason and the response is not: the reason distinguishes "not a member of this tenant" from "a member with no role", and telling the caller which would confirm that a guessed tenant exists.

Source
can?(permission : String, tenant : String | Nil = nil, resource : Authz::Authorizable | Nil = nil, attributes : Hash(String, String) | Nil = nil) : Bool

Whether the current request may perform permission. For a template deciding whether to render a button — never as the guard itself, which is authorize! at the point of action.

Source
credential

The principal, or nil. Prefer require! in a guarded route: it makes the principal available only where it exists, with no nil check. The credential that proved this request, when one was presented.

What an audit line records, what a "used by token X" screen renders, and what an application's own per-credential policy reads. Safe by construction: it carries no secret, no digest and no signature, so it cannot leak one into a log or a template.

nil when nobody is signed in, and also for a principal no credential produced.

Source
csrf_anchor

What the CSRF token is bound to: the session id when signed in, the anchor cookie when not. nil when there is neither, which is a rejection rather than a token check against an empty string.

Source
csrf_token

A CSRF token for this request, minting an anchor if there is not one yet.

Call it from whatever renders a form:

<input type="hidden" name="_csrf" value="<%= env.auth.csrf_token %>">

For an authenticated request the token binds to the session, so it changes when the session does — including on login, which is what makes a token minted before authentication useless afterwards. For an anonymous one it binds to a dedicated cookie, issued lazily right here: rendering a form is what creates it, so a static asset request never pays for one.

The value differs on every call even within a request. That is the mask (KemalIdentity::Kemal::CSRF), and it is why a token that repeats in every response cannot be extracted by a compression oracle.

Source
csrf_valid?(token : String | Nil) : Bool

Whether token is valid for this request. For an application checking by hand rather than through CSRFHandler.

Source
elevate!(result : MFA::Verified) : Principal

Records a proved second factor, and raises this session to the assurance that proof earns — AssuranceLevel::MFA for a factor, Recovery for a spent recovery code.

case result = KemalIdentity.app.mfa!.verify(env.auth.require!.subject, env.params.body["code"])
in KemalIdentity::MFA::Verified then env.auth.elevate!(result)
in KemalIdentity::Failed        then render_the_same_error_for_every_reason
end

The same line takes a recovery redemption, and lands on Recovery instead, because the result says which happened. That is the whole point of this method existing next to #mfa_verified! and #recovery_verified!: MFA::Service#verify and #redeem_recovery_code return the same MFA::Verified, so an application picking between those two by hand is one forgotten branch away from letting a printed code satisfy minimum_assurance: MFA — which is blueprints/0025 MFA-04 arriving through the front door instead. Every framework surveyed in blueprints/0030 gets this wrong in exactly that way; the fix is to stop asking the application.

Failed cannot be passed, so separating success from failure stays the compiler's business rather than a convention.

Monotone. A session already at MFA that spends a recovery code stays at MFA: the factor really was proved earlier in this session and that fact did not expire. Only the ceiling is monotone — mfa_verified_at is restamped either way, so recency still measures from the most recent second-factor event.

This rotates the session, exactly as login does. docs/02-security-model.md lists an assurance increase alongside login among the events that must produce a new identifier: a session id an attacker learned while it was worth Password must not silently become one worth MFA.

Source
failed?

A credential was presented and rejected. Distinct from anonymous? because this is the case whose cookie needs clearing.

Source
failure_reason

Why the presented credential was rejected, for logging. Never for the response: a body that varies with this is an account oracle (docs/04-kemal-integration.md).

Source
logout!

Ends the current session and clears the cookie. Safe to call when nobody is signed in.

Also forgets this browser's remember-me family. Skipping that would sign the user out and then sign them straight back in on their next request, which is not what a logout button is for.

Source
mfa_verified!

Records that a second factor was proved, and raises this session to AssuranceLevel::MFA.

Superseded by #elevate!, which reads the level out of the verification result rather than trusting the caller to have branched. Prefer that: this method cannot tell a factor from a recovery code, so calling it on the wrong path is how a printed list comes to satisfy minimum_assurance: MFA.

Not for a recovery code. #recovery_verified! is that one, and it stops at AssuranceLevel::Recovery — a printed list is not a hardware key, and this method used to be documented for both.

Rotates the session, like every other assurance increase. mfa_verified_at is stamped from the application's clock, so require_assurance! and a freshness window both measure from when the factor was actually proved.

Source
outcome

The raw result. Exhaustively matchable, for an application that wants to distinguish "not signed in" from "this cookie is no good".

Source
password_verified!

Records that the password was typed again on this session, and stamps the evidence #require_recent_password! reads.

Call it after re-verifying the password for somebody who is already signed in:

case KemalIdentity.app.passwords.authenticate(login: login, password: typed)
in KemalIdentity::Authenticated then env.auth.password_verified!
in KemalIdentity::Failed, KemalIdentity::Anonymous
  env.status(401).text("Invalid password")
end

Not start!(result.principal). That would work and it would also throw the session back down to AssuranceLevel::Password, because a fresh password authentication is worth exactly that — so confirming a password inside an MFA session would silently undo the second factor. This method takes the level from the session that already exists and only ever raises it, exactly like #elevate!.

A login needs none of this: Passwords::Authenticator stamps the evidence onto the principal it returns, and #start! carries it.

Rotates the session, like every other proof recorded against it.

Source
principal?
Source
recovery_verified!

Records that a recovery code was spent, and raises this session to AssuranceLevel::Recovery — above Password, below MFA.

Superseded by #elevate!, which arrives at the same level from the verification result. Prefer that: nothing then depends on the application having chosen this method over #mfa_verified! on the right path.

The level is the whole point: somebody who has lost their device gets back in, and an action guarded by minimum_assurance: MFA stays shut until they enrol a real factor again. Prompt for that enrolment right here — a session sitting at Recovery is a half-finished recovery, not a normal signed-in state.

Monotone, like #elevate!: a session that already proved a factor keeps MFA, because spending a recovery code afterwards does not unprove the device.

Rotates the session, like every other assurance increase. mfa_verified_at is stamped: it records when the last second-factor event happened, and how strong that event was is assurance, which is where an application should read strength from.

Source
remember!(principal : Principal | Nil = nil) : Nil

Starts remembering this browser, and writes the cookie.

Call it only after a real authentication — remember refuses a restored session by taking an account rather than a principal, because chaining remembrance off remembrance would make the thirty days a rolling window that never closes.

env.auth.start!(result.principal)
env.auth.remember! if env.params.body["remember"]?
Source
require!

The principal, or NotAuthenticatedError — which ErrorHandler turns into a 401.

Raising is deliberate, and is the one place this shard raises on the authentication path other than require_fresh!: it lets a route guard be a single line whose result is the principal, rather than a conditional that has to remember to return.

Source
require_assurance!(level : AssuranceLevel) : Principal

The principal, if it reached at least level.

Source
require_fresh!(within : Time::Span) : Principal

The principal, if the credential behind it was verified within within.

Raises FreshAuthenticationRequiredError, which becomes a 403: the caller is known, they simply have to prove it again. The window is the caller's choice rather than a configured global, because "recent enough" for changing an email is not "recent enough" for deleting an account.

A session restored from a remember-me cookie is never fresh, however recently it was restored — it proves possession of a stored token, not the presence of the account holder. Step-up therefore always forces a real re-authentication out of Remembered.

Operations that must call this: changing email, changing password, disabling MFA, generating or revoking API credentials, and any destructive account action.

Source
require_recent_password!(within : Time::Span) : Principal

The principal, if the password behind it was typed within within.

Not the same question as #require_fresh!, and the difference is the point. Freshness asks when the credential behind assurance was last verified, and assurance is restamped by every increase — so a second factor proved nine minutes after login satisfies a ten-minute freshness window that meant "type your password again". And a federated login sits at AssuranceLevel::Password too, so no level distinguishes "they know the password" from "their identity provider vouched for them".

This is the guard for an operation whose whole security rests on the password itself: linking a new federated identity to the account, changing the password, turning off MFA. Laravel's password.confirm middleware and django-sudo's @sudo_required are the same guard; blueprints/0031 measures both.

Refuses — never raises — for a principal no password produced: a remembered browser, a bearer token, a federated login, an account with no password at all. That is the fail-closed direction. An application whose people sign in only through a provider cannot satisfy this guard, and should be asking #require_fresh! or a provider re-authentication instead of this.

post "/settings/identities" do |env|
  env.auth.require_recent_password!(within: 10.minutes)
  # ...
end
Source
start!(principal : Principal, assurance : AssuranceLevel | Nil = nil, mfa_verified_at : Time | Nil = nil, password_verified_at : Time | Nil = nil) : Principal

Mints a session for principal and sets the cookie.

This is the session fixation defence. Any session the client held before this call is revoked, so an identifier an attacker planted beforehand is worthless afterwards. The new session is created before the old one is revoked, so a crash in between leaves the user where they were rather than logged out.

docs/04-kemal-integration.md writes this as sessions.start!(env, principal). It lives here instead: a core service taking an HTTP::Server::Context would put HTTP into the layer that is meant not to know HTTP exists. See blueprints/0008-kemal-layer-owns-the-http-seam.md.

Source