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
aaData→dataBC 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 forauthserver.user_activity_log. It recordslogin,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/renamedandtwofactor_enabled/disabled. It no-ops when theauthfeature 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 ownAddon\User\*handler (e.g. the reference application) take the addon path and are never double-logged. - Session tracking —
Application::bootSessionTracking()runsSessionTrackingMiddlewareautomatically so thesessionstable (active devices, force-logout) is populated with zero wiring. Scaffolded apps instead declare the middleware explicitly ('middleware' => [...]) and run it through a pipeline inwww/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()setssessions.logout = 1; the tracking layer force-logs-out that device on its next request). - Web-session token on login — a
usertokensrow (web_session) is created on built-in login and invalidated on logout, so per-request activity is attributed intokenactions(already logged byApplication::exec()).
Fixed¶
User::addToken()used anON 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 inAccount(user_activity_log,user_twofactor, …). Unqualified names silently resolved againstpublicon PostgreSQL (whosesearch_pathlacksauthserver) 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.
- Public:
Security¶
- The password is never round-tripped through the step-up form —
LoginFlowkeeps 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\Dashboardis now a thin, backward-compatible subclass ofAccount(it only pins the historicalDashboardroute base). Existing routes, scaffolds and apps referencingDashboardkeep working unchanged; new code should useAccount.
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.phpandaccount/login_2fa.html.phpfor theplain-css,bootstrapandtailwindthemes. They drive theAccount/LoginFlowflow 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 (LoginFlowholds 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.
- the login form submits once to
Account::brand()— a settings-driven branding seam passed to the views:auth_brand_name(falls back tositename, 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
accountview group and are entirely separate from the existinglogingroup used by the scaffoldedLogincontroller, 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. PluspendingUserId()andcancel()for the controller to render / abandon a step-up.
Pramnos\Auth\LoginFlowResult— an immutable value object describing every outcome (SUCCESS,FAILED,LOCKEDwith remaining seconds,STEP_UP_REQUIREDwith 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 insideAuth::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 viapf-webauthn.jsagainst the existing JSON endpoints.- Bundled
passkey/manage.html.phpviews for the plain-CSS, Bootstrap and Tailwind themes (framework fallbacks; publishable viaproject: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):passkeyOptionsissues assertion options pinned to the pending user and stashes the challenge server-side;passkeyVerifyverifies the assertion and finishes the login viaLoginFlow::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-freewindow.PramnosWebAuthnhelper:supported(),authenticate()(assertion / login / step-up) andregister()(dashboard). It converts the server's base64url options toArrayBuffers fornavigator.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_2faviews (all three themes) show a "Use a passkey" button when a passkey is offered, wired topf-webauthn.jswith 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
authscaffold now also generatesPasskeyandSessioncontrollers. - The
authserverscaffold now also generatesDiscovery,Device,Gdpr,CapabilitiesandInternalPermissionscontrollers.
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 aaData → data 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.2andv1.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 senddraw, soDatasource::getList()returns rows underdatainstead ofaaData. Application endpoints that fetch an unencoded result, decorate rows in PHP, and re-encode them (the hand-writtengetJsonList()/data()pattern) silently stop decorating and the grid throwsRequested unknown parameter 'N'. The v1.2 reference now carries a prominent warning with the one-line$rowsKeyfix, and the Upgrade Guide covers the full migration plus a regression-test recipe.