Skip to content

16 July 2026

Built-in login flow, account controller & passkey UI. The scaffoldable auth server takes shape: the overridable LoginFlow state machine, a built-in Account controller for login/verify/logout, branded built-in login views, passkey management + second-factor step-up, full auth-controller scaffolding, and the Upgrade Guide / DataTables BC note.

8 changes:

  • Account activity log, session tracking & active-device management
  • Account controller: built-in login / verify / logout
  • Built-in login views + branding seam
  • LoginFlow: the overridable password → step-up → session state machine
  • Passkey management page + reachable from the account UI
  • Passkey second-factor step-up + WebAuthn browser glue
  • Scaffolding exposes every auth controller
  • Upgrade Guide + DataTables aaDatadata BC note

Account activity log, session tracking & active-device management

The built-in authserver now records account activity, tracks logged-in devices, and lets a user sign out other sessions — all wired into the account UI, with zero impact on apps that bring their own login stack.

Added

  • Pramnos\Auth\ActivityLog — a single, self-guarding writer for authserver.user_activity_log. It records login, logout, login_failed, account_locked, password_changed, password_reset_requested, password_reset_completed, application_authorized, application_revoked, data_export_requested, privacy_settings_updated, passkey_added/removed/renamed and twofactor_enabled/disabled. It no-ops when the auth feature or the table is absent, and never throws into the caller.
  • Login/logout logging lives in the built-in lifecycle only (Auth::executeDefaultLogin/Logout). Apps that register their own Addon\User\* handler (e.g. the reference application) take the addon path and are never double-logged.
  • Session trackingApplication::bootSessionTracking() runs SessionTrackingMiddleware automatically so the sessions table (active devices, force-logout) is populated with zero wiring. Scaffolded apps instead declare the middleware explicitly ('middleware' => [...]) and run it through a pipeline in www/index.php; the auto-run then stands down so tracking happens exactly once.
  • Active Sessions on the Security page — lists the user's devices and lets them sign out any other session (Account::revokesession() sets sessions.logout = 1; the tracking layer force-logs-out that device on its next request).
  • Web-session token on login — a usertokens row (web_session) is created on built-in login and invalidated on logout, so per-request activity is attributed in tokenactions (already logged by Application::exec()).

Fixed

  • User::addToken() used an ON CONFLICT (userid, tokentype, token) upsert with no matching unique constraint — it threw on PostgreSQL and silently degraded to a plain insert on MySQL. It is now a plain insert (tokens are unique random values).
  • authserver.* tables are now schema-qualified in Account (user_activity_log, user_twofactor, …). Unqualified names silently resolved against public on PostgreSQL (whose search_path lacks authserver) and failed without an exception, leaving the activity list and 2FA status blank.

Account controller: built-in login / verify / logout

The framework's account controller is promoted from Dashboard to a general Pramnos\Auth\Controllers\Account that now also hosts the public login flow a scaffolded auth server needs out of the box — password login, 2FA step-up, and logout — driven by the LoginFlow orchestrator.

Added

  • Pramnos\Auth\Controllers\Account — one controller spanning the whole account lifecycle, split by authentication:
    • Public: login (GET form / POST credential leg), verify (complete a pending 2FA step-up), logout.
    • Authenticated: the existing account-management surface (dashboard, profile, applications, security, change-password, GDPR export/erasure, privacy). The login actions delegate every decision to LoginFlow, so a fresh app gets working password + 2FA login with no custom code. Each render, redirect, and collaborator is a protected seam, so an app rebrands or re-wires one piece by subclassing.

Security

  • The password is never round-tripped through the step-up form — LoginFlow keeps the pending login server-side.
  • Every state-changing POST (login, verify) is CSRF-checked.
  • The ?return= post-login redirect is sanitised: cross-origin, protocol-relative (//host) and control-character targets are rejected; only same-origin absolute URLs and site-relative paths are honoured.

Changed

  • Pramnos\Auth\Controllers\Dashboard is now a thin, backward-compatible subclass of Account (it only pins the historical Dashboard route base). Existing routes, scaffolds and apps referencing Dashboard keep working unchanged; new code should use Account.

Notes

  • A passkey second-factor step-up rides the same pending state via LoginFlow::completePasskey(); its browser ceremony is wired in a later phase alongside the WebAuthn front-end and the built-in views.

Built-in login views + branding seam

The Account controller now ships bundled login and second-factor views across all three scaffold themes (plain-CSS, Bootstrap, Tailwind), so a fresh auth server renders a working login UI with no view files of its own — and rebrands it by setting a few settings keys.

Added

  • Bundled account/login.html.php and account/login_2fa.html.php for the plain-css, bootstrap and tailwind themes. They drive the Account/LoginFlow flow directly:
    • the login form submits once to <routeBase>/login;
    • the step-up form submits only the code to <routeBase>/verify — the password is never placed in a hidden field (LoginFlow holds the pending login server-side), replacing the legacy base64-password round-trip;
    • a "remember me" checkbox, a backup-code entry, a lockout countdown, and friendly messages for each error key.
  • Account::brand() — a settings-driven branding seam passed to the views:
    • auth_brand_name (falls back to sitename, then "Sign in"),
    • auth_brand_logo, auth_brand_primary_color (default #2563eb),
    • auth_brand_footer. Override the method or set the keys to rebrand; no view edits required.

Notes

  • These new views live under the account view group and are entirely separate from the existing login group used by the scaffolded Login controller, so nothing changes for apps on the older flow.
  • The passkey second-factor option shown on the step-up screen, plus the WebAuthn browser glue, arrive in the next phase.

LoginFlow: the overridable password → step-up → session state machine

Pramnos\Auth\LoginFlow composes the framework's existing auth building blocks into the canonical login flow a scaffolded auth server adopts with zero custom code: verify a password, honour the brute-force lockout, step up to a second factor when the account requires one, and only then establish the session.

Added

  • Pramnos\Auth\LoginFlow — an overridable orchestrator with three entry points:
    • attempt(username, password, remember) — checks the lockout before touching credentials, verifies the password, and then either logs the user in directly or, when a second factor is required, stashes a server-side pending step-up and asks for it.
    • completeTwoFactor(code) — finishes a pending login with a TOTP / backup code; a wrong code leaves the pending state intact so the user can retry.
    • completePasskey(verifiedUserId) — finishes a pending login with a passkey that was cryptographically verified for the same user who passed the password leg. Plus pendingUserId() and cancel() for the controller to render / abandon a step-up.
  • Pramnos\Auth\LoginFlowResult — an immutable value object describing every outcome (SUCCESS, FAILED, LOCKED with remaining seconds, STEP_UP_REQUIRED with the offered methods), so a controller branches on one thing.

Security

  • The pending state between legs holds only the user id, the remember flag and the lockout identifier — never the password. Nothing sensitive is round-tripped through a hidden form field, and a step-up can only complete a login this same session started.
  • The lockout gate runs before any credential check; a failed password records one attempt against a case-normalised identifier so the progressive lockout can escalate.
  • A pending step-up expires after 5 minutes and is scrubbed on read, so a stale half-login can never be completed later.

Notes

  • This is a new entry point (backward-compatible, additive). Apps with their own login controller are unaffected — it relies only on the additive Auth::loginById() for session bootstrap and never enforces a second factor inside Auth::auth().
  • Every collaborator and policy decision (stepUpMethods(), the lockout identifier, the step-up window, and each service) is a protected seam, so a scaffolded app can change one rule by subclassing instead of forking.

Passkey management page + reachable from the account UI

Passkeys can now be managed from the account UI. Previously the passkey endpoints existed but no page linked to them — a user could never actually reach passkey management. That gap is closed.

Added

  • Pramnos\Auth\Controllers\Passkey::display() — an HTML management page (auth-only) that lists the user's passkeys and lets them add, rename and revoke, all client-side via pf-webauthn.js against the existing JSON endpoints.
  • Bundled passkey/manage.html.php views for the plain-CSS, Bootstrap and Tailwind themes (framework fallbacks; publishable via project:publish-views).

Fixed

  • Reachability: the account dashboard sidebar and the Security page now link to Passkeys (alongside Two-Factor Auth and Change Password), so every built-in account-security feature is reachable through the UI — no orphan pages.

Passkey second-factor step-up + WebAuthn browser glue

The built-in login flow can now complete a pending second factor with a passkey, not just a TOTP code — and ships the dependency-free WebAuthn browser glue that drives it.

Added

  • Account::passkeyOptions() / Account::passkeyVerify() — two JSON endpoints for a passkey step-up. They only work while a login is pending (after the password leg): passkeyOptions issues assertion options pinned to the pending user and stashes the challenge server-side; passkeyVerify verifies the assertion and finishes the login via LoginFlow::completePasskey(), which succeeds only when the passkey resolves the same user who passed the password. On success it returns the post-login redirect target.
  • scaffolding/assets/js/pf-webauthn.js — a small, dependency-free window.PramnosWebAuthn helper: supported(), authenticate() (assertion / login / step-up) and register() (dashboard). It converts the server's base64url options to ArrayBuffers for navigator.credentials, serialises the authenticator response back to the standard base64url WebAuthn JSON, and posts same-origin with the session cookie. Copied into scaffolded apps and loaded from the theme footers.
  • Passkey option on the step-up screen — the built-in login_2fa views (all three themes) show a "Use a passkey" button when a passkey is offered, wired to pf-webauthn.js with graceful degradation (hidden when WebAuthn is unavailable; the TOTP / backup-code path always remains).

Security

  • The step-up challenge is single-use and server-side; a passkey belonging to a different account can never complete someone else's pending login (enforced by both the pinned ceremony and LoginFlow::completePasskey()'s user match).

Scaffolding exposes every auth controller

pramnos init now generates a thin wrapper for every framework auth controller, so a scaffolded auth server exposes the full surface out of the box — nothing is silently missing.

Added

  • The auth scaffold now also generates Passkey and Session controllers.
  • The authserver scaffold now also generates Discovery, Device, Gdpr, Capabilities and InternalPermissions controllers.

Each is a thin extends of its framework counterpart under Pramnos\Auth\Controllers (via the new writeAuthControllerWrapper() helper), so all logic stays in the framework while the app decides — by having the file — which URLs are routable. Nothing is auto-routed behind the app developer's back: routing stays explicit and opt-in, and the generated controllers-contract test verifies each wrapper extends the right base.

The two internal endpoints (Capabilities, InternalPermissions) authenticate via client credentials, not the user session, so exposing their URL is safe.


Upgrade Guide + DataTables aaDatadata BC note

Added a dedicated Upgrade Guide (Version-to-Version) that documents the concrete migration steps between releases, and clarified a behavioural breaking change from v1.2 that was previously under-documented.

Documentation

  • New: Upgrade Guide. A version-to-version guide with a general upgrade loop (preconditions → steps → rollback) plus per-version sections for v1.1 → v1.2 and v1.0 → v1.1, each with a breaking-changes table and a validation checklist. Wired into the site nav under Version History.

Fixed / Clarified

  • DataTables server-side AJAX BC note. The v1.2 reference stated that legacy DataTables callers were "unchanged". That is true at the method-signature level, but \Pramnos\Html\Datatable::renderJs() now makes the client send draw, so Datasource::getList() returns rows under data instead of aaData. Application endpoints that fetch an unencoded result, decorate rows in PHP, and re-encode them (the hand-written getJsonList() / data() pattern) silently stop decorating and the grid throws Requested unknown parameter 'N'. The v1.2 reference now carries a prominent warning with the one-line $rowsKey fix, and the Upgrade Guide covers the full migration plus a regression-test recipe.