bherila / auth-laravel
Shared Laravel authentication services for BWH applications.
Requires
- php: ^8.4
- illuminate/auth: ^13.0
- illuminate/contracts: ^13.0
- illuminate/database: ^13.0
- illuminate/hashing: ^13.0
- illuminate/http: ^13.0
- illuminate/mail: ^13.0
- illuminate/queue: ^13.0
- illuminate/routing: ^13.0
- illuminate/support: ^13.0
- illuminate/validation: ^13.0
- lcobucci/jwt: ^5.6
- symfony/uid: ^7.4 || ^8.0
- web-auth/webauthn-lib: ^5.3.5
Requires (Dev)
- laravel/passport: ^13.0
- laravel/pint: ^1.29.3
- orchestra/testbench: ^11.0
Suggests
- laravel/passport: Required only when using the optional OAuth authorization-server helpers.
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v0.25.0
- v0.24.4
- v0.24.3
- v0.24.2
- v0.24.1
- v0.24.0
- v0.23.2
- v0.23.1
- v0.23.0
- v0.22.1
- v0.22.0
- v0.21.0
- v0.20.0
- v0.19.0
- v0.18.0
- v0.17.0
- v0.16.0
- v0.15.0
- v0.14.0
- v0.13.0
- v0.12.2
- v0.12.1
- v0.12.0
- v0.11.0
- v0.10.0
- v0.9.0
- v0.8.0
- v0.7.1
- v0.7.0
- v0.6.3
- v0.6.2
- v0.6.1
- v0.6.0
- v0.5.2
- v0.5.1
- v0.5.0
- v0.4.3
- v0.4.2
- v0.4.1
- v0.4.0
- v0.3.0
- v0.2.0
- v0.1.2
- v0.1.1
- v0.1.0
- dev-release/v0.25.0
- dev-feat/application-invitations
- dev-release/v0.24.4
- dev-fix/dcr-accept-secret-auth-methods
- dev-release-v0.24.3
- dev-fix/identity-cache-identical-write
- dev-docs/release-v0.24.2
- dev-docs/release-runbook
- dev-chore/shipped-migration-style
- dev-fix/nonce-migration-connection-91
- dev-release-v0.24.1
- dev-fix/refresh-token-client-ownership
- dev-release/v0.15.0
- dev-docs/release-v0.14.0
- dev-feat/delegated-contract-v2
- dev-docs/v0.13.0-changelog
- dev-feat/provider-session-status
- dev-fix/introspection-invalid-token-result
- dev-release/v0.11.0-docs
- dev-feat/mcp-oauth-resource-binding
- dev-claude/v0-9-0-maintenance-compat-39o7ma
- dev-codex/document-login-throttle-contract
- dev-codex/shared-auth-harden
This package is auto-updated.
Last update: 2026-10-12 19:57:28 UTC
README
Shared Laravel auth package for BWH applications.
Release procedure and upgrade requirements: RELEASING.md.
The companion React component package is
bwh-auth.
Includes:
- OAuth 2.0 authorization-code client mechanics with PKCE and validated identity responses
- Opt-in provider browser-session generation verification
- Opt-in identity tombstone consumption: apply the identity provider's deletions through an application-bound handler and acknowledge each one after local deletion commits
- Application-requested admission invitations: a client that asks the identity provider to invite someone to this application only, with typed results and failures, and an in-memory fake for application tests
- opt-in Passport authorization-server helpers for metadata, dynamic public-client registration, S256 PKCE, RFC 8707 resource binding, RFC 7662 backchannel introspection, and a shared consent experience
- WebAuthn/passkey registration and login
- email-code 2FA challenge service, API routes, and mailables
- password reset request/reset/change API routes and mailables
- passkey and 2FA database tables
- login audit logging: an owned
auth_audit_logtable, a default database logger, binary IP storage, optional read endpoints, and opt-in retention (see "Login audit logging") - policy and audit contracts for app-specific behavior
Upgrading to v0.12.0 (separate OAuth resource servers)
This release adds authenticated RFC 7662 token introspection for a protected resource that is deployed separately from its Passport authorization server. It also makes the three resource-aware Passport repositories extensible so an authorization-server app can layer account, grant, or credential-version policy on top of the package checks without replacing resource binding.
The authorization server owns the route and explicitly opts in. Pin each confidential introspection client to one exact resource in server-side configuration; the request cannot select or broaden that resource:
use BWH\Auth\Http\Controllers\OAuthTokenIntrospectionController; Route::post('/oauth/introspect', OAuthTokenIntrospectionController::class) ->middleware('throttle:60,1'); 'oauth_server' => [ // existing server configuration... 'introspection' => [ 'enabled' => true, 'clients' => [[ 'id' => env('OAUTH_INTROSPECTION_CLIENT_ID'), // Store only password_hash($secret, PASSWORD_DEFAULT) here. 'secret_hash' => env('OAUTH_INTROSPECTION_CLIENT_SECRET_HASH'), 'resource' => 'https://resource.example.test/mcp', ]], ], ],
The resource server configures the same confidential credential and its expected
issuer/resource, then resolves OAuthTokenIntrospector. The remote implementation does
not positively cache responses: revocation and authorization-server account policy are
therefore rechecked before every protected request.
use BWH\Auth\OAuth\Introspection\OAuthTokenIntrospector; $token = app(OAuthTokenIntrospector::class)->introspect($request->bearerToken() ?? '');
Set OAUTH_INTROSPECTION_ENDPOINT, OAUTH_INTROSPECTION_CLIENT_ID,
OAUTH_INTROSPECTION_CLIENT_SECRET, OAUTH_RESOURCE_ISSUER, and
OAUTH_RESOURCE_URI. A token the authorization server reports as inactive returns
active=false without claims. An active: true token with malformed claims, missing
binding, the wrong issuer/resource/audience, an expired exp, or a future nbf throws
OAuthTokenValidationException. Map either outcome to a 401 invalid_token response
with the appropriate WWW-Authenticate challenge so the client can reauthorize.
Configuration, connection, HTTP/client-authentication, and malformed response-envelope
failures throw OAuthIntrospectionException; map those to 503 because retrying may
succeed. Catch the validation subtype before its parent:
use BWH\Auth\OAuth\Introspection\OAuthIntrospectionException; use BWH\Auth\OAuth\Introspection\OAuthTokenValidationException; try { $token = app(OAuthTokenIntrospector::class)->introspect($request->bearerToken() ?? ''); } catch (OAuthTokenValidationException) { // 401 invalid_token } catch (OAuthIntrospectionException) { // 503 authorization server unavailable }
OAuthTokenValidationException extends OAuthIntrospectionException, so existing broad
catches remain source-compatible while consumers adopt the more precise mapping.
The endpoint must use HTTPS (except loopback development) and the issuer's exact origin;
redirects are never followed, so neither the bearer token nor confidential-client
credential can be forwarded to another host.
The resource application must still map sub to a local account and enforce all local
authorization policy itself.
Upgrading to v0.10.0 (breaking)
Runtime floor. This release requires PHP 8.4+ and Laravel 13, dropping
PHP 8.3 and Laravel 12. Earlier releases advertised PHP 8.2 and Laravel 12 or 13, but
the code used typed class constants (PHP 8.3+) and CI only ever installed one
combination, so neither claim held. The supported set is now one line, and CI runs the
floor, the newest runtime, and --prefer-lowest.
Dropping a platform is a breaking change, so this ships as v0.10.0, not a 0.9.x
patch: for a pre-1.0 package ^0.9 means >=0.9.0 <0.10.0, which is exactly the
boundary that keeps a consumer still on PHP 8.3 from being upgraded into a package it
cannot run. Update the requirement deliberately, alongside the runtime:
composer require bherila/auth-laravel:^0.10
Run the migrations. Package migrations are published, not loaded, so
composer update alone does not apply them. Passkey registration writes rp_id,
which means an app that has not applied 2026_08_24_120000_add_rp_id_to_passkey_credentials
fails when a user enrolls a passkey:
php artisan vendor:publish --tag=bherila-auth-migrations php artisan migrate
Republishing the config is optional now. Package defaults are merged into the
published config key by key, so a config/bherila-auth.php that predates a release
no longer erases the nested defaults added since. Republish (--tag=bherila-auth-config,
--force) only if you want the new keys and comments in the file itself.
Behaviour changes to check before deploying:
two_factor.allow_test_codenow defaults to false, and even when true the fixed code is accepted only in the environments listed intwo_factor.test_code_environments(local,testing) and only for accounts flaggedis_test. Previously either the setting or anis_testaccount was enough, and the setting defaulted to on everywhere exceptAPP_ENV=production— a staging deploy accepted999999for every account. All three conditions are required, so the environment variable alone is no longer enough outsidelocalandtesting: a staging deploy that genuinely needs the fixed code must setBHERILA_AUTH_ALLOW_TEST_2FA_CODE=true, add its environment totwo_factor.test_code_environmentsin the published config, and flag the specific accountsis_test.canLogin()is now rechecked before the 2FA login completes and before the post-reset auto-login. A password reset still succeeds for a disallowed account, but it no longer hands out a session./api/change-passwordand the authenticated passkey routes now carryRequireActiveUserin addition toauth, so they answer 403 for an account your policy will not log in.POST /api/auth/forgot-passwordgoes through Laravel's password broker, so a second request within the broker'sthrottlewindow (config/auth.php, 60 seconds by default) sends no mail. The response is unchanged.POST /api/auth/two-factor/resendrefuses an expired attempt instead of issuing a fresh code for it.- The package dispatches Laravel's
PasswordResetLinkSentandPasswordResetevents.
OAuth/MCP authorization-server changes described below ship in v0.11.0. Consumers should not enable
oauth_server.enabled until they have run the OAuth metadata migration and added the
resource-aware Passport middleware/configuration.
Upgrading to v0.11.0 (OAuth/MCP server)
This is an opt-in server capability. Existing OAuth clients and the identity-provider
role keep Passport's normal unbound-token behavior unless an application enables
oauth_server.enabled and routes the server helpers. When Passport is installed, the
package keeps validation of previously issued resource-bound tokens active even after
the issuance switch is disabled; it delegates unbound token issuance and persistence to
Passport while disabled. An application that currently owns custom resource-aware Passport
repositories should migrate to the package repositories only after verifying its
configured resource and applying the new migration; remove duplicate bindings so one
repository is responsible for the resource checks.
The configured resource must match what the MCP client sends and what the protected
endpoint represents. For example, if the endpoint is /api/v1/mcp, do not silently use
/api/v1 unless that base URI is intentionally the resource covering all of those
endpoints. Update the protected-resource metadata URL and API WWW-Authenticate
challenge at the same time. If the metadata URL is omitted, the helper derives the
RFC 9728 path-based well-known URL from resource.
Upgrading to v0.5.0 (breaking)
This release adds a required method to the AuthUserPolicy contract:
public function canLogin(Authenticatable $user, Request $request): bool;
It is the single gate for account-state checks (active, approved, not disabled)
and is now enforced by the new RequireActiveUser middleware on the package's
audit routes, so a role-only admin gate can no longer let a pending or disabled
account through.
Because the contract gained a required method, this is a breaking change and
must be released as v0.5.0 (not a 0.4.x patch) so consumers opt in. Consuming
apps that implement AuthUserPolicy directly must add canLogin() when they
upgrade — typically delegating to their model:
public function canLogin(Authenticatable $user, Request $request): bool { return $user instanceof User && $user->canLogin() && $user->hasVerifiedEmail(); }
Apps that extend DefaultAuthUserPolicy inherit a working canLogin() (it
duck-types $user->canLogin(), falls back to is_disabled, defaults to true)
and need no change.
Install
composer require bherila/auth-laravel php artisan vendor:publish --tag=bherila-auth-config php artisan vendor:publish --tag=bherila-auth-migrations php artisan migrate
Routes are auto-registered by BWH\\Auth\\AuthServiceProvider; consuming apps should not copy or publish package route files. Publishable assets are limited to config, migrations, and optional mail views.
Publish mail views only if the consuming app wants to customize the package email templates:
php artisan vendor:publish --tag=bherila-auth-views
Consumers install from Packagist and should not add a GitHub VCS repository entry. For unreleased local package development only, use a Composer path repository:
{
"repositories": [
{
"type": "path",
"url": "../auth-laravel",
"options": { "symlink": true }
}
]
}
Then run composer require bherila/auth-laravel:@dev. Composer reads the
repository-root composer.json and autoloads the package from src. Remove the
path override before validating a published release.
Configuration
Published config lives at config/bherila-auth.php. Important settings:
routes.prefix: defaults toapi, so package endpoints are under/api/....routes.middleware: defaults to['web']so session auth and CSRF work in Laravel/Vite apps.routes.passkeys,routes.password_resets,routes.change_password, androutes.two_factor: enable or disable route families independently when an app owns one part of the auth surface locally.password_resets.reset_url: reset-page URL generated into password reset emails. Defaults to{APP_URL}/reset-password/{token}?email={email}.password_resets.verify_email_on_reset: optionally marks verified-email users verified after a successful reset.password_resets.redirect_after_reset: JSON redirect returned after a successful reset.BHERILA_AUTH_PASSWORD_RESET_MAIL_SUBJECT: optional reset-link mailable subject override.BHERILA_AUTH_PASSWORD_NOTICE_MAIL_SUBJECT: optional password reset/change notice subject override.two_factor.expires_minutes: 2FA code expiry. Defaults to 15 minutes.two_factor.allow_test_code: allows the configured test code. Defaults tofalse; when enabled it applies only intwo_factor.test_code_environmentsand only to accounts flaggedis_test.two_factor.test_code_environments: environments in which the test code may be honoured. Defaults to['local', 'testing']; an empty list means any environment.BHERILA_AUTH_TWO_FACTOR_MAIL_SUBJECT: optional email 2FA subject override.passkeys.user_verification: WebAuthn user verification requirement. Defaults topreferred; set torequiredfor passkeys used as a stronger security factor.passkeys.resident_key: WebAuthn resident key requirement. Defaults topreferred.throttle.enabled: enables audit-log-backed password-login lockout. Defaults tofalse.throttle.max_attempts: failed attempts allowed for the same key before lockout. Defaults to5.throttle.decay_minutes: lockout/window length. Defaults to15.throttle.key: how failed attempts are grouped —email(per account, across all IPs),ip(per source, across all accounts), oremail_ip(per account+source pair). Defaults toemail_ip; any unrecognized value falls back toemail_ip.users.force_change_password_attribute: optional boolean column to clear after password reset/change, such asforce_change_pw.migrations.drop_tables_on_rollback: defaults tofalseso package rollbacks do not drop existing app auth tables.
Enabling throttle.enabled only changes the package service behavior. Apps that use their own password-login controller must still call ThrottlesLoginAttempts or the LoginThrottle contract from that controller before attempting credentials. Publishing the config alone does not intercept custom /login routes.
The package migration uses Schema::hasTable() before creating its tables. This lets existing apps such as bwh-php keep an already-created passkey table without migration failure. It does not alter existing tables, so apps with older or different schemas should either point the package config at compatible tables or add an app-local migration for schema reconciliation. Rollback table drops are disabled by default to avoid deleting pre-existing auth data.
API routes
With the default prefix, the package registers:
POST /api/auth/forgot-passwordPOST /api/auth/reset-passwordPOST /api/change-passwordPOST /api/auth/two-factor/verifyPOST /api/auth/two-factor/resendGET /api/auth/two-factor/confirm/{token}POST /api/auth/two-factor/confirm/{token}GET /api/auth/two-factor/report/{token}POST /api/auth/two-factor/report/{token}GET /api/passkeysPOST /api/passkeys/register/optionsPOST /api/passkeys/registerDELETE /api/passkeys/{id}POST /api/passkeys/auth/optionsPOST /api/passkeys/auth
When audit.routes_enabled is true (off by default), it also registers GET /api/auth/audit-log, POST /api/auth/audit-log/{id}/suspicious, and GET /api/auth/audit-log/all. See "Login audit logging".
Ownership boundary
This package owns Laravel services, API routes, database migrations, controllers, and auth mailables. It intentionally does not ship application page Blade wrappers or Vite entrypoints. Each consuming app should create its own Blade pages and Vite entrypoints, then mount the shared bwh-auth React components where useful.
The package does include Markdown Blade templates for its mailables under bherila-auth::emails.*. Those are email templates, not page wrappers, and can be published/overridden with php artisan vendor:publish --tag=bherila-auth-views.
Password reset integration
The package owns the JSON API endpoints and mailables. The consuming app owns the pages, including Blade wrappers and Vite entrypoints.
Create pages such as /forgot-password and /reset-password/{token} and mount
the shared bwh-auth components from the companion auth-react repository:
import { PasswordResetRequestForm, ResetPasswordForm } from 'bwh-auth'; import { getAuthComponents } from '@/lib/auth-components'; export function ForgotPasswordPage() { return <PasswordResetRequestForm components={getAuthComponents()} />; } export function ResetPasswordPage({ token, email }: { token: string; email: string }) { return <ResetPasswordForm components={getAuthComponents()} token={token} email={email} />; }
The reset email uses password_resets.reset_url. Set BHERILA_AUTH_PASSWORD_RESET_URL when the app uses a different route shape.
Authenticated password change
The package registers POST /api/change-password behind auth middleware. It expects current_password, password, and password_confirmation, updates the authenticated user's password, sends PasswordResetNoticeMail, and returns JSON suitable for bwh-auth's ChangePasswordForm.
The consuming app owns where this appears, such as an account settings page or dialog.
Email 2FA integration
The package intentionally does not own password credential login because each app has different user approval, lockout, and onboarding rules. After the consuming app verifies email/password and decides the user may proceed, start a package 2FA challenge instead of logging in immediately:
use BWH\Auth\Services\TwoFactorService; $attempt = app(TwoFactorService::class)->startChallenge( $user, $request, $request->boolean('remember'), ); return response()->json([ 'success' => true, 'requires_2fa' => true, 'attempt_token' => $attempt->token, 'message' => 'A verification code has been sent to your email address.', ]);
The consuming app also owns the 2FA page wrapper and Vite entrypoint, for example /login/two-factor/{token}:
import { TwoFactorForm } from 'bwh-auth'; import { getAuthComponents } from '@/lib/auth-components'; export function TwoFactorPage({ token }: { token: string }) { return <TwoFactorForm components={getAuthComponents()} attemptToken={token} />; }
TwoFactorForm posts to /api/auth/two-factor/verify, can resend via /api/auth/two-factor/resend, and can report suspicious attempts via POST /api/auth/two-factor/report/{token}.
Email confirmation and report links are side-effect-free GET pages. The user must submit a CSRF-protected POST to complete login or report suspicious activity, which prevents common email security scanners from consuming one-shot login links.
Mailables
Included mailables for password reset, password reset/change notices, and email 2FA:
BWH\Auth\Mail\TwoFactorLoginMailBWH\Auth\Mail\PasswordResetMailBWH\Auth\Mail\PasswordResetNoticeMail
Views are loaded from the bherila-auth::emails.* namespace and can be overridden by publishing Laravel views if needed.
App Integration
OAuth authorization-server integration
Applications exposing a Passport-protected API can reuse the package's server-side
protocol and consent UX without enabling any routes automatically. Configure
bherila-auth.oauth_server, then point application-owned routes and middleware at:
BWH\Auth\Http\Controllers\OAuthMetadataControllerBWH\Auth\Http\Controllers\OAuthDynamicClientRegistrationControllerBWH\Auth\Http\Middleware\EnsureOAuthServerEnabledBWH\Auth\Http\Middleware\EnforceOAuthPkceBWH\Auth\Http\Middleware\EnforceOAuthResourceIndicatorBWH\Auth\Http\Middleware\ExpectOAuthResourceBWH\Auth\Http\Middleware\AppendOAuthAuthorizationResponseIssuer(only when RFC 9207 is enabled)BWH\Auth\OAuth\Server\OAuthProtectedResourcebherila-auth::oauth.authorize
The smallest MCP server configuration is conceptually:
'oauth_server' => [ 'enabled' => true, 'issuer' => 'https://example.test', 'resource' => 'https://example.test/mcp', 'protected_resource_metadata_url' => 'https://example.test/.well-known/oauth-protected-resource/mcp', 'scopes' => [ 'mcp:use' => 'Connect through MCP', ], 'resource_required_scopes' => ['mcp:use'], ],
Register the same application-owned catalog with Passport during application boot; package configuration defines policy but does not mutate Passport's global scope registry:
Passport::tokensCan(config('bherila-auth.oauth_server.scopes', []));
resource is the exact protected-resource identifier, not merely the authorization
server origin; its path and trailing slash are significant. The application declares which entries in its own scope catalog require
that resource. Set protected_resource_scopes when this protected resource should
advertise only a subset of the server catalog. The package carries the validated value through authorization state,
consent, the authorization-code record, token exchange, refresh-token exchange, the
signed JWT aud (and resource) claims, and the access- and refresh-token records. Its
Passport repository binding also checks the signed audience and issuer on every bearer
request, so a token issued for one configured resource cannot be replayed at another one.
Authorization resource state spans the authorization, login, and consent requests.
Configure Laravel's default cache repository as a persistent store shared by every
authorization-server node; the array store is request/process-local and is not
suitable. If that state is unavailable, the package fails closed rather than issuing
an unbound credential. Keep its TTL at least as long as the browser session lifetime
(the package defaults to that lifetime) and do not evict the configured
authorization_state.cache_prefix during an active authorization flow.
Add EnsureOAuthServerEnabled before the PKCE/resource middleware on every Passport
authorization and token route. The package's metadata and registration controllers
already honor the switch themselves, but Passport routes remain application-owned and
registered independently. Without this route middleware, changing
oauth_server.enabled to false does not by itself stop Passport from processing an
existing client or refresh grant. The switch hides issuance routes; revoke existing
credentials separately when an incident requires immediate credential invalidation.
When Passport is installed, the package keeps its resource-aware access-token repository
bound so outstanding bound credentials remain audience-restricted. Enabling
oauth_server.enabled additionally binds the resource-aware authorization-code and
refresh-token repositories and access-token entity. Routes are still application-owned.
A typical app exposes the two metadata controller methods and
uses Passport's routes with EnsureOAuthServerEnabled, EnforceOAuthPkce, and
EnforceOAuthResourceIndicator.
If the application has its own Passport client model, extend ResourceClient (or apply
the same dynamic-client firstParty()/skipsAuthorization() behavior) rather than
replacing that model with Passport's default; the package never overrides a custom model.
For an API challenge, return OAuthProtectedResource::unauthorizedResponse() (or
insufficientScopeResponse([...])) so WWW-Authenticate includes the configured
resource_metadata URI.
Put ExpectOAuthResource before auth:api (or call
OAuthResourceIndicator::expectConfiguredFor($request) before invoking Passport's
resource server directly) on every endpoint that accepts the bound credential.
Resource-bound tokens fail closed on Passport-protected routes that do not declare
the expected audience, preventing an MCP token from being replayed at a different
API in the same application.
The package migration 2026_09_02_000000_add_oauth_server_metadata adds the reusable
Passport client registration fields and authorization-code, access-token, and refresh-token
resource-binding fields when they are absent. Refresh tokens retain their resource directly,
so Passport may purge an expired access-token row without invalidating a longer-lived refresh.
Publish and run migrations before enabling the server. If an application uses custom
column names or a custom Passport client model, configure auth_code_resource_column,
resource_column, and refresh_token_resource_column, and ensure the scopes attribute
is stored as an array/collection cast or JSON/string list the middleware can normalize.
Auth-code scope persistence likewise honors array, JSON, and collection casts rather than
double-encoding them. A missing resource column fails closed before a bound credential
can be issued. Enabling
the resource-aware Passport binding also switches new access tokens to the package
issuer/audience format; legacy Passport JWTs without the package iss claim are
rejected by the resource repository. Revoke or allow existing access tokens to expire,
then reauthorize clients after the migration rather than carrying old bearer tokens
across the cutover.
Dynamic registration is opt-in in metadata: configure a registration endpoint and keep
dynamic_clients.enabled true only when the application has routed the controller.
Unknown metadata is ignored as required by RFC 7591. The accepted profile is a public
authorization-code client: authorization_code + refresh_token, code, and none.
Native clients may use HTTPS or explicit loopback development redirect URIs; hosted/web
clients must use HTTPS redirect URIs. The response never contains a reusable client
secret. A supplied registration scope is an upper bound; an omitted scope explicitly
registers the configured server catalog as the upper bound, while an explicitly empty
scope registers no scopes. On authorization requests, an omitted scope uses the
Passport default scopes; an explicitly empty scope is rejected rather than silently
falling back to those defaults.
Registered-scope enforcement is always on for dynamic clients; the legacy
enforce_registered_scopes setting is retained only so published configs remain readable.
Dynamic registration does not grant consent or bypass the application policy.
Authorization and consent responses are non-cacheable. Package pre-validation redirects
errors only to an active client's exact registered callback; when redirect_uri is
omitted, the sole registered callback is used, while ambiguous, malformed, unknown, or
revoked-client destinations receive a local error instead.
RFC 9207 issuer identification is disabled by default. If enabled, install the issuer middleware on every Passport authorization/consent route; otherwise leave the metadata flag disabled. The package currently advertises DCR for compatibility but does not advertise Client ID Metadata Documents and does not fetch arbitrary client URLs. CIMD support is intentionally deferred until URL-client identity resolution and hardened SSRF-safe document retrieval can be shipped together; see the focused CIMD design issue.
For a manual Codex smoke test, expose the protected-resource metadata and challenge routes publicly, configure DCR, and run the client against the exact protected-resource URL:
codex mcp add example --url https://example.test/mcp codex mcp login example
The package test suite exercises the Passport lifecycle and registration fixtures; a deployed application should still complete this browser/consent check in Codex (and, when applicable, ChatGPT developer mode) before claiming client interoperability.
The shared consent view uses oauth_server.consent copy and labels so applications can
retain domain-specific language without copying security-sensitive forms or styling.
It warns when a client registered dynamically and shows the validated return URI.
Agent API preset
An application that lets agents act for its users (MCP clients, and REST/OpenAPI connectors) runs its own authorization server through this package. The identity provider only signs people in. AgentOAuthServer applies the whole profile from one call, with every URL derived from APP_URL, so forks and self-hosted deployments need no host-specific settings:
// config/bherila-auth.php use BWH\Auth\OAuth\Server\AgentOAuthServer; 'oauth_server' => AgentOAuthServer::config(App\Support\Scopes::descriptions(), [ 'resource_required_scopes' => ['mcp:use'], // if an MCP connection scope must carry a resource ]), // config/passport.php 'middleware' => AgentOAuthServer::passportMiddleware(), // routes/web.php (outside the web group: machine endpoints) AgentOAuthServer::routes();
| Setting | Value |
|---|---|
| Resource | APP_URL/api/v1 (RFC 8707). An omitted resource is taken as this one; a different explicit resource is refused. |
| PKCE | S256 required for every client |
| Self-registration | POST /oauth/register, public clients only, throttle:10,60. A request for client_secret_post or client_secret_basic is accepted and registered public; the response says none. A refusal's error_description names the fields at fault. |
| Token endpoint auth methods advertised | none, client_secret_basic, client_secret_post. Confidential clients are only ones a person registers. |
| Discovery routes | /.well-known/oauth-authorization-server and /.well-known/oauth-protected-resource/api/v1. That's the only path whose resource matches, per RFC 9728. Point your MCP endpoint's WWW-Authenticate resource_metadata at it. A deployment mounted under a path must route the host-root well-known URL to the app. |
| Kill switch | OAUTH_SERVER_ENABLED |
| Passport side (applied by the package) | The scope catalog through Passport::tokensCan() (unless you register your own), the packaged consent view (unless you bind one), and the device-code grant off, because it would bypass the PKCE gate |
Overrides merge recursively, and a list replaces the preset's list outright.
Several protected resources (REST and MCP)
RFC 9728 requires a protected-resource document's resource to be identical to the identifier the client used, both at the path-inserted well-known URL and when reached from a WWW-Authenticate challenge. An MCP endpoint at /api/v1/mcp is therefore a resource of its own, not part of /api/v1. Declare each endpoint (and each alias, such as /mcp) as its own resource:
'oauth_server' => AgentOAuthServer::config($scopes, [ 'resources' => [ 'rest' => ['path' => '/api/v1', 'scopes' => [...$moduleScopes]], 'mcp' => ['path' => '/api/v1/mcp', 'scopes' => ['mcp:use', ...$moduleScopes]], 'mcp_alias' => ['path' => '/mcp', 'scopes' => ['mcp:use', ...$moduleScopes]], ], 'assume_omitted_resource' => 'rest', // clients that send no `resource` get REST tokens 'resource_required_scopes' => ['mcp:use'], ]),
Route::middleware([ExpectOAuthResource::class.':rest', 'auth:api'])->group(...); // /api/v1/... Route::post('/api/v1/mcp', ...)->middleware([ExpectOAuthResource::class.':mcp', 'auth:api']); Route::post('/mcp', ...)->middleware([ExpectOAuthResource::class.':mcp_alias', 'auth:api']);
- Documents.
AgentOAuthServer::routes()serves one document per resource at its own path-inserted URL (/.well-known/oauth-protected-resource/api/v1,.../api/v1/mcp,.../mcp), each naming exactly that resource and its ownscopes_supported. - Binding. An authorization or token request must name one configured resource; naming several is refused (
invalid_target). An omittedresourcebinds toassume_omitted_resource. A token is accepted only on routes expecting its resource: an endpoint and its alias are separate audiences, so a connector authorizes again if it moves between them. - Scope ceilings. A resource admits only the scopes it lists. A scope may sit under several resources (module scopes on both REST and MCP); the ceiling keeps
mcp:useoff REST tokens and REST-only scopes off MCP tokens. - Challenges. In the exception handler,
OAuthProtectedResource::unauthenticated($request)returns a 401 whoseresource_metadatanames the route's own resource, from configuration rather than the request host. Test each endpoint withassertProtectedResourceChallenge('POST', '/api/v1/mcp', $mcpResource)fromAssertsAgentOAuthContract, which follows the challenge as a strict client does. - Clients that never send
resource(common among chat-client connectors): withassume_omitted_resource, they get the default resource when their scopes fit it, otherwise the first configured resource whose ceiling admits them, and their exchanges and refreshes keep it. Custom code or refresh-token repositories must useOAuthResourceIndicator::exchangeResource(). Test it withassertOmittedResourceConnection($user, 'mcp:use ...', 'POST', '/api/v1/mcp'). - Personal tokens bind to
credentials.resource(a resource name), or the default resource. - Without
resources,resourceis the single resource and nothing changes.
Browser-based agents (CORS)
oauth_server.cors.allowed_origins lists the origins (or *) allowed to call discovery, registration and the token endpoint from a browser. Listed origins get Access-Control-Allow-Origin (never credentials) and preflights; any other origin gets no CORS headers, and requests without an Origin are unaffected. An MCP endpoint's own origin policy remains the application's.
Accounts the application has disabled
Publish and run the package migrations first (2026_10_11_000000_add_provider_identity_to_oauth_credentials adds the refresh-token owner column the policy needs), then bind a BWH\Auth\OAuth\Credentials\CredentialOwnerPolicy to refuse OAuth credentials for accounts your application considers unable to hold them (disabled, suspended, not approved). Every authorization code, access token (agent and personal) and refresh token is checked when issued, exchanged, refreshed and used; a refused refresh is refused without consuming the refresh token. When disabling an account, also call app(OAuthCredentialOwners::class)->revokeAll($user) so re-enabling it does not revive old credentials; for an account your application can no longer load (deleted, or hidden from the default query), call revokeAllForOwnerId($id). It is separate from AuthUserPolicy::canLogin(), which answers whether someone may sign in interactively.
Agent preset operations
- Prune stale self-registrations daily:
Schedule::command('bherila-auth:prune-dynamic-clients')->daily();. The retention window isoauth_server.dynamic_clients.retention_days, andlast_used_at_columnmust be set for recent use to count. - Test your app's contract:
use BWH\Auth\Testing\AssertsAgentOAuthContract;in a feature test, then callassertAgentOAuthDiscovery()andassertAgentOAuthLifecycle($user, 'your:scope', '/api/v1/some-protected-route'). - Keys:
php artisan passport:keys, kept out of version control, with the path set bypassport.key_pathorPASSPORT_*_KEY.
API credentials (OAuth apps and personal API tokens)
Some agent connectors run the OAuth authorization-code flow; others ask for an API key. Enable the credential service to let a signed-in person create either kind for themselves:
'oauth_server' => AgentOAuthServer::config($scopes, [ 'credentials' => [ 'enabled' => true, 'token_lifetimes' => ['PT4H', 'P30D', 'P90D', 'P365D'], ], ]),
Routes (under credentials.prefix, session middleware ['web', 'auth']):
| Method and path | Does |
|---|---|
GET / |
Scopes on offer, lifetimes, and the person's tokens and apps, with finished URLs |
POST /tokens |
Create an API token |
DELETE /tokens/{id} |
Revoke a token |
POST /apps |
Register an OAuth app |
DELETE /apps/{id} |
Delete an app |
All return JSON. Each new secret appears once, in a 201 no-store body. While the OAuth server is switched off, the two POST routes answer 404 and the index returns null issuance URLs.
Bind GrantableScopes to offer only the scopes your REST operations use. Bind CredentialOwnerResolver, or set credentials.owner_model, when your API guard loads a different model from the web guard.
Refresh-token client ownership
Publish and run 2026_10_11_120000_add_client_id_to_oauth_refresh_tokens before purging more expired access-token rows. It adds a nullable, indexed client_id to the refresh-token table on the Passport connection and backfills rows whose access token still exists. An existing application-owned column and its non-null values are preserved; rollback retains the column.
The resource repository records the client from the validated grant on issuance and rotation. Deleting an app then revokes its refresh credentials even after their access-token rows disappear, while leaving other clients' credentials alone. Linked-access revocation still works with unmigrated schemas and nullable legacy associations. Already orphaned rows without a stored client association cannot be recovered by this migration and must be revoked separately.
Personal API tokens that open an MCP connection (opt-in)
By default no credential the service issues carries an MCP connection scope (any resource_required_scopes entry, such as mcp:use): a pasted API token reaches REST and per-module endpoints, never an endpoint that requires the connection scope. Some agent clients connect to a remote MCP server only with a static bearer key and cannot run the authorization-code flow. To let personal API tokens serve them, name the connection scopes a token may carry:
'credentials' => [ 'enabled' => true, 'token_lifetimes' => ['PT4H', 'P30D', 'P90D', 'P365D'], 'personal_token_connection_scopes' => ['mcp:use'], // default []: off 'personal_token_connection_max_lifetime' => 'P30D', // the default ],
The tradeoff. Such a token is a long-lived static key with connection rights. Nobody sees a consent screen naming the client that uses it, it works from anywhere until it expires or is revoked, and anyone who copies it can connect as its owner. Prefer an OAuth app, or the dynamic-registration flow, for any client that can run OAuth; turn this on only for the ones that cannot.
What the opt-in does, and does not:
- Offer. Only listed scopes that are also
resource_required_scopesentries, in the scope catalog, and not incredentials.excluded_scopes. They are offered to personal tokens only, never to OAuth apps (which reach a connection through consent). The index addstoken_connection_scopes,connection_token_lifetimes, and aconnectionflag on each listed token; with the opt-in off the index is unchanged. - Cap. A token carrying any connection scope may have only an offered lifetime no longer than
personal_token_connection_max_lifetime; a longer request is refused (422). An invalid value offers no connection scope at all. Once opted in, the cap also covers a connection scope a customGrantableScopesbinding already offered. - Bind. Like every personal token, it is bound to the configured protected resource (RFC 8707): the row's resource column, the JWT
audandresourceclaims. It is accepted only on routes marked withExpectOAuthResourcefor that resource, and the route still checks scopes, so a token without the connection scope is refused at the connection endpoint. - Unchanged. Issuance stays browser-only with the secret returned once (no-store); listing, revocation and expiry behave as for any personal token. Removing a scope from the list stops new tokens but does not revoke tokens already issued: revoke those from the listing (they are flagged
connection), and keep the cap short so they age out.
OAuth client integration
BWH\Auth\OAuth\OAuthClient owns state and PKCE generation, authorization redirects,
authorization-code exchange, and validation of the provider identity response. The consuming
application still owns local-user lookup/provisioning, account-state policy, login, auditing,
and the post-login destination.
Configure OAUTH_PROVIDER, OAUTH_PROVIDER_URL, OAUTH_CLIENT_ID,
OAUTH_CLIENT_SECRET, and OAUTH_REDIRECT_URI, then delegate from the app controller:
public function redirect(Request $request, OAuthClient $oauth): RedirectResponse { return $oauth->redirect($request); } public function callback(Request $request, OAuthClient $oauth): RedirectResponse { $identity = $oauth->identityFromCallback($request); $user = $this->resolveLocalUser($identity); Auth::login($user); $request->session()->regenerate(); return redirect()->intended('/'); }
The package deliberately does not match or bind users by email. That decision is application-specific and must not silently replace a trusted provider-subject binding.
Signing out, and the application list
Two things every relying party needs, and which four of them had drifted apart on before they were lifted here.
BWH\Auth\Concerns\SignsOutThroughProvider ends the local session and then hands off to
the provider's end-session endpoint. Ending only the local session is not signing out: the
provider still recognises the person, so the next protected page sends them back for
authorization and is handed an identity with no prompt — a button that visibly does nothing.
class OAuthLoginController extends Controller { use SignsOutThroughProvider; public function logout(Request $request, OAuthClient $oauth): RedirectResponse { return $this->signOutThroughProvider($request, $oauth); } // Optional; a no-op unless the application keeps an audit trail. protected function afterLocalSignOut(Request $request, ?Authenticatable $user): void { $this->auditLoggedOut($request, $user); } }
Pass a second argument to choose where the provider returns the person; it defaults to this application's root and must be absolute, because the provider validates it against the origins registered for this client. An address it does not recognise lands them on the provider rather than being followed, so a misconfiguration degrades instead of becoming an open redirect.
BWH\Auth\OAuth\ProviderApplications holds the sibling applications the provider reported,
so an app switcher can render without a second round trip. Store it in the callback once the
application has decided to admit the person, and read it where the page is composed:
ProviderApplications::remember($request, $identity->apps); // in callback() ProviderApplications::forRequest($request); // in Blade, Inertia, a view composer
Keeping it server-side is the point: the set of applications that exist is never compiled into a JavaScript bundle, so downloading the front end tells an anonymous visitor nothing about what else is deployed. Gate the injection on the person being signed in.
OAuthClient::isConfigured() answers whether a client has been issued for this application
without aborting, which the other methods cannot do — they 503 on a missing setting, the
right answer for a half-configured deploy and the wrong one for an app that is meant to run
without a provider at all. Use it to 404 the OAuth routes, or to fall back to a local
sign-in, in environments that have no client.
Bind BWH\Auth\Contracts\AuthUserPolicy when an app needs custom login gates or redirects.
Bind BWH\Auth\Contracts\AuthAuditLogger only when an app wants to override the built-in audit behavior (for example, to mirror events into its own broader audit table). Most apps should instead use the database driver described below.
canLogin() — the single gate for account state
AuthUserPolicy::canLogin() is the single source of truth for "is this account allowed to proceed through any login flow." The default implementation duck-types $user->canLogin() and falls back to checking $user->is_disabled. Apps with additional account-state columns (e.g. approved_at, email_verified_at, or a role whitelist) must bind a custom policy and encode all conditions in canLogin():
// app/Auth/AppUserPolicy.php class AppUserPolicy extends DefaultAuthUserPolicy { public function canLogin(Authenticatable $user, Request $request): bool { return $user->approved_at !== null && ! $user->is_disabled; } } // AppServiceProvider::register() $this->app->bind(AuthUserPolicy::class, AppUserPolicy::class);
The package calls canLogin() automatically from:
RequireActiveUsermiddleware, applied to all package audit-log routescanPasskeyLogin()in the default policy (passkey auth delegates here)- The 2FA
completeLogin()path delegates throughredirectAfterLogin(); if the user should be blocked at that point,canLogin()must return false so the redirect sends them away from the app
Apps must also call canLogin() from their own:
- Primary password-login controller — before
Auth::attempt()or after resolving the user. - Email-verification callback — after marking the email verified, call
canLogin()and useredirectAfterLogin()(not a hardcoded path) so a just-verified but still-pending user goes to the pending page rather than into the app. Hardcoding/pendingin the verification handler causes approved users who verified their email to be falsely shown the pending page.
Protecting admin gates against pending/disabled accounts
When setting audit.admin_ability, the Gate ability definition must verify both admin role and active-account state. The package applies RequireActiveUser on top, but your Gate definition should be correct independently (it may be called from other locations):
// AppServiceProvider::boot() Gate::define('admin-only', function (User $user) { // WRONG: only checks role — a pending admin bypasses account-state checks // return $user->is_admin; // CORRECT: role AND account state return $user->is_admin && $user->approved_at !== null && ! $user->is_disabled; });
Backfilling approved_at after adding an approval column
If you add an approved_at (nullable, null = pending) column to your existing users table, every pre-existing row will be null after migration, instantly locking out all current users — including the primary admin. Before deploying the migration to production, add a backfill step in your migration (or a separate migration) to grandfather existing rows:
// In your migration's up() method, after adding the column: DB::table('users') ->whereNull('approved_at') ->update(['approved_at' => now()]);
Alternatively, make null mean "approved" and use a different sentinel (e.g. a pending boolean), but document the convention clearly.
Login audit logging
The package can own a single append-only audit log for authentication events, so consuming apps no longer hand-roll their own login-audit table and writer.
Enable it
php artisan vendor:publish --tag=bherila-auth-config php artisan vendor:publish --tag=bherila-auth-migrations php artisan migrate
Then set the driver in .env:
BHERILA_AUTH_AUDIT_DRIVER=database
The driver defaults to null (a no-op NullAuthAuditLogger), so an app that has not published/run the migration is unaffected and never hits a missing table. Setting it to database binds DatabaseAuthAuditLogger, which writes one row per event into the bherila-auth.audit.table table (default auth_audit_log).
What gets recorded
The package's own controllers/services already report passkey, 2FA, and password reset/change events. For primary password login and logout, the package does not own the login controller, so the app calls the contract from its own login flow. Use the LogsAuthEvents trait:
use BWH\Auth\Concerns\LogsAuthEvents; class LoginController { use LogsAuthEvents; public function login(Request $request) { // ... resolve $user, attempt credentials ... if (! $ok) { $this->auditLoginFailed($request, $user, $request->input('email'), 'Invalid credentials'); // ... } $this->auditLoginSucceeded($request, $user); // method defaults to 'password' } public function logout(Request $request) { $this->auditLoggedOut($request, $request->user()); // ... } }
auth_method is a free-form string (e.g. password, passkey, two_factor, dev). Failed logins for unknown emails are recorded with a null user_id and the attempted email.
Schema
auth_audit_log columns: id, user_id (nullable), acting_user_id (nullable), email, event, auth_method, succeeded, reason, ip_address (varbinary(16) on MySQL / blob on SQLite, via BWH\Auth\Casts\BinaryIpAddressCast), user_agent, session_id, is_suspicious, metadata (json), timestamps. Event-name constants live on BWH\Auth\Models\AuthAuditLog (EVENT_LOGIN_SUCCEEDED, etc.). The client IP is resolved via BWH\Auth\Support\ClientIp, which uses Laravel's Request::ip() — so it only honours X-Forwarded-* headers from configured trusted proxies. Apps behind Cloudflare or a load balancer must configure Laravel's TrustProxies (in bootstrap/app.php) so the real client IP is recorded; otherwise forwarded headers are ignored to prevent audit-log IP spoofing.
Read endpoints (optional)
Set BHERILA_AUTH_AUDIT_ROUTES=true to register read endpoints (the package ships no UI; render your own and call these or query AuthAuditLog):
GET /api/auth/audit-log— the authenticated user's own history (paginated)POST /api/auth/audit-log/{id}/suspicious— flag/unflag one of the user's own entriesGET /api/auth/audit-log/all— cross-user admin list, gated by thebherila-auth.audit.admin_abilityGate ability (route returns 403 unless that ability is configured and allowed)
Retention
Retention is off by default (bherila-auth.audit.retention_days = null), so nothing is ever pruned. To enable pruning, set BHERILA_AUTH_AUDIT_RETENTION_DAYS and run the artisan command:
php artisan bherila-auth:prune-audit-log
The command deletes all rows older than bherila-auth.audit.retention_days and prints the count of removed rows. It is a no-op when retention_days is null.
To run it automatically, add it to your application's scheduler (optional):
// bootstrap/app.php or a scheduler $schedule->command('bherila-auth:prune-audit-log')->daily();
You can also continue to use Laravel's built-in prune infrastructure if you prefer:
Schedule::command('model:prune', ['--model' => [\BWH\Auth\Models\AuthAuditLog::class]])->daily();
Since 0.4.2. The
bherila-auth:prune-audit-logartisan command was added in 0.4.2.
Since 0.2.0. The audit-log table, default database logger,
BinaryIpAddressCast,ClientIp, theLogsAuthEventstrait, read endpoints, retention, and theloginSucceeded/loginFailed/loggedOutcontract methods were added in 0.2.0. The contract gained methods; implementations should extendBWH\Auth\Services\AbstractAuthAuditLogger(which provides no-op defaults) rather than implementing the interface directly.
Trusted proxies
Every per-client limit, including login throttling, client registration, token exchange and your own API throttles, keys on Request::ip(). Behind a CDN that's the edge's address unless the edge is trusted, so every client behind one edge shares a budget. Trusting * is unsafe wherever the origin also answers direct connections, because anyone could forge their address.
Opt in to let the package configure Laravel's TrustProxies for you:
BHERILA_AUTH_TRUSTED_PROXIES=true TRUSTED_PROXIES=cloudflare # or a comma-separated list, or * behind a firewall, or empty
TRUSTED_PROXIES |
Trusts |
|---|---|
cloudflare (default) |
Cloudflare's published IPv4/IPv6 ranges, shipped with the package (TrustedProxies::CLOUDFLARE), or bherila-auth.trusted_proxies.cloudflare if you pin your own list |
10.0.0.1, 192.0.2.0/24 |
exactly those addresses and ranges; cloudflare can appear in the list too |
* |
every peer; only safe when a firewall admits nothing but the proxy |
| empty | nothing; right when no proxy is in front |
- Headers honoured: only
X-Forwarded-Forand-Proto. NeverX-Forwarded-Port(Cloudflare passes a client-supplied one through) orX-Forwarded-Host. - The client address is the rightmost one that isn't a trusted proxy, i.e. the address the edge appended. Anything the client wrote further left is ignored.
- Drift check: run
php artisan bherila-auth:check-cloudflare-rangesfrom a scheduled CI job. It exits1with an add/remove list when Cloudflare revises its ranges, and2if they can't be fetched.
Login throttling
The package can also enforce a password-login lockout using the same append-only auth_audit_log table. It is off by default and has no effect until a consuming app enables it and calls the service from its own password-login controller:
BHERILA_AUTH_AUDIT_DRIVER=database
BHERILA_AUTH_THROTTLE_ENABLED=true
BHERILA_AUTH_THROTTLE_MAX_ATTEMPTS=5
BHERILA_AUTH_THROTTLE_DECAY_MINUTES=15
# email | ip | email_ip (default)
BHERILA_AUTH_THROTTLE_KEY=email_ip
This package does not wrap arbitrary app /login routes. If the app disables package auth routes or owns primary login locally, the local login controller is responsible for inspecting the throttle before Auth::attempt() and recording blocked attempts.
Use BWH\Auth\Concerns\ThrottlesLoginAttempts alongside LogsAuthEvents:
use BWH\Auth\Concerns\LogsAuthEvents; use BWH\Auth\Concerns\ThrottlesLoginAttempts; class LoginController { use LogsAuthEvents; use ThrottlesLoginAttempts; public function login(Request $request) { $email = $request->input('email'); $state = $this->inspectLoginThrottle($request, null, $email); if ($state->locked) { $this->auditLoginBlocked($request, null, $email, 'password', $state); return response()->json([ 'message' => 'Too many login attempts.', 'retry_after' => $state->availableInSeconds(), ], 429); } // ... attempt credentials ... if (! $ok) { $this->auditLoginFailed($request, $user, $email, 'Invalid credentials'); // ... } $this->auditLoginSucceeded($request, $user); } }
The throttle counts recent login_failed rows matching the auth method and the configured throttle.key: the normalized email (email), the resolved client IP (ip), or both (email_ip, the default). Second-factor failures (two_factor_failed) are excluded by event, so a wrong 2FA code never counts toward credential lockout. A later login_succeeded row for the same key resets the count. Blocked requests can be recorded as login_blocked rows via auditLoginBlocked(), but those rows do not extend the lockout window.
Pick the key strategy to match your threat model: email mitigates per-account credential stuffing but lets an attacker lock a victim out by spamming failures for their address; ip bounds a single noisy source but can affect users behind a shared NAT/CGNAT egress; email_ip (default) is the most conservative and only locks a specific account+source pair.
Because throttling is audit-log-backed, apps must enable the database audit driver, run the package audit migration, record failed/successful primary login events, and configure Laravel trusted proxies correctly.
Delegated application access verification
BWH\Auth\OAuth\DelegatedAccess provides consumer primitives for the
delegated application access contract.
It does not install a signing service or grant application access, and it exposes a route only
when an application binds an adapter (Serving the endpoint).
lcobucci/jwt is an explicit runtime dependency; Passport
remains optional for consumers that do not run an authorization server.
Construct ActorAssertionVerifier from trusted local configuration: the exact
HTTPS issuer, the exact HTTPS adapter endpoint, the application registry key,
a kid => RSA public key PEM map, and a NonceStore. Issuers may not contain a
path other than /; a trailing issuer slash is removed before exact claim matching.
URLs may not contain credentials, query strings, or fragments.
Keys and endpoint URLs are never discovered from assertion headers.
use BWH\Auth\OAuth\DelegatedAccess\ActorAssertionVerifier; use BWH\Auth\OAuth\DelegatedAccess\DatabaseNonceStore; $verifier = new ActorAssertionVerifier( issuer: 'https://identity.example.test', endpoint: 'https://application.example.test/application-access', application: 'example-app', publicKeys: ['integration-v1' => $pinnedPublicKeyPem], nonces: new DatabaseNonceStore($dedicatedNonceDatabaseConnection), ); $actorSubject = $verifier->verify($assertion, $request->method(), $request->getContent());
Verification requires RS256, the application-access+jwt type, an exact audience,
issuer, application and POST method, and the SHA-256 of the original request
bytes. Assertions last at most 60 seconds with five seconds of clock tolerance.
Successful verification atomically consumes the nonce through expiry plus that
tolerance. DatabaseNonceStore writes a dedicated bherila_auth_delegated_nonces
table, independently of application cache. Cache flushes and Redis evictions
cannot erase these records. Provision the table before enabling the adapter:
php artisan vendor:publish --tag=bherila-auth-delegated-access-migrations
Apply the published migration through the application's normal reviewed deployment
process, on the same connection passed to the store. It is not published with the
ordinary package migrations, and its rollback deliberately retains nonce records.
The store's database connection must be shared and durable across every adapter
worker and deployment, and must be outside any business transaction so a later
rollback cannot undo consumption. In-memory SQLite and active transactions are
rejected. Use the primary writable database connection; do not place this table in
an ephemeral database or restore it to an earlier snapshot while assertions remain
valid. Only a unique-key conflict is treated as replay; other database failures
become a 503 refusal. pruneExpired() may be scheduled for maintenance and deletes
only expired entries. There is no ordinary cache adapter or fallback.
Custom NonceStore implementations must provide equivalent durable atomic
first-use semantics through expiry, including restarts, maintenance, and failure
handling. Never catch a replay-storage failure and continue authentication.
The returned subject identifies the actor only. Resolve that actor using the configured issuer and exact subject, then enforce current local eligibility and access-administration authority before target discovery or mutation. Resolve targets separately; verification does not authorize them, provision users, or replace application-owned tenant, last-administrator, revision, or audit rules. No ordinary OAuth-token fallback is permitted on the adapter endpoint.
DelegatedContract::request() validates operation input and builds its
envelope. response() validates an envelope; pass the expected target subject as
its fourth argument for read, update and remove to enforce exact subject echo.
Consumers must separately enforce MAX_REQUEST_BYTES/MAX_RESPONSE_BYTES, JSON
parsing, transport authentication, and operation authorization. The contract
bounds subjects to 191 bytes, revisions to 128 bytes, cursors to 512 bytes,
pages to 50 entries, and access updates to 100 unique workspace memberships.
An unprovisioned response carries null revision/access and no allowed edits.
DelegatedAccessException exposes a generic outcome and HTTP status; do not
log assertions or private key material when handling it.
The contract
The contract has one version, 3 (DelegatedContract::VERSION_3, the default for request() and
response()). Versions 1 and 2 were removed in 0.22.0; the endpoint refuses a request in either as
invalid_request (422), and asking DelegatedContract for any version but 3 is a configuration
error (unsupported_contract_version, status 500), not a refusal of any request.
An application describes its own workspace roles, and a provider may provision an account for a subject the application has not seen yet (#42).
capabilities.controlsis exactly{application_admin, workspace_roles, provisioning}.workspace_roleslists up to 16{id, label}entries (each may add adescription), ids up to 64 bytes and labels up to 255, most senior first. An empty list makes the application account-only (below).access.workspaces[]is{id, role}in an update and{id, role, editable}in a read or update response. A membership reportededitable: falsemust be sent back unchanged, and the application refuses an update that changes it.rolesAreAdvertised($capabilities, $access)checks an access value against the roles a capabilities response advertised;advertisedRoleIds()lists them.allowed_editsis exactly{application_admin, workspaces, provision, remove}. An unprovisioned response still carries null revision and access, no administrator or workspace edits and no removal, but may setprovision: true. A provisioned response always sets it false.- An
updatewhoseexpected_revisionisnullprovisions an unprovisioned subject, and only that update may carrydisplay_name(up to 255 bytes): contact data for the new account, never an identity key. The application still binds the account to the verified issuer and the exact subject, and answers 409 when the subject is already provisioned.
Account-only applications
An application with accounts but no workspaces advertises workspace_roles: []. It is then
account-only: what can be managed is whether a person has an account, through provisioning, and
the application administrator flag.
- Every access value carries
workspaces: []: anupdateand a provisioningupdatesend it, and aread,updateorremovestate reports it. A state never offersallowed_edits.workspaces. The application answersworkspaceswith an empty page and refuses an update naming any membership (invalid_requestorrole_not_grantable). - Provisioning sends
application_adminas the actor chose it. A provider asks for that choice explicitly rather than defaulting it, and the application still decides whether this actor may create an administrator. accountOnly($capabilities)says whether a capabilities response describes one.rolesAreAdvertised($capabilities, $access)accepts onlyworkspaces: []for it, as it accepts only advertised roles otherwise, so a provider's existing check covers outgoing access.fitsCapabilities($capabilities, $response)checks an answer the shape validators cannot: for an account-only application, no memberships and no workspace edits in a state and an emptyworkspacespage. It is always true for a workspace application.
The empty role list is the signal, rather than a separate flag such as controls.workspaces: false:
- One spelling, no contradictions. Roles are the only way a membership can be expressed, so an application with none can hold no memberships whatever a flag said. A flag would add two states to define and refuse (no workspaces with roles, workspaces without any) and say nothing new.
- Workspace applications are untouched. Their capabilities, adapters and providers keep exactly
the shape they have;
controlskeeps its exact key set. A new key would be required of every application, or optional with two spellings of the same answer. - Older providers fail closed. A provider on an earlier release refuses
workspace_roles: []asinvalid_response, as it would refuse an unknown key, so it shows an error rather than a workspace screen with no roles to choose. - A misconfigured workspace application cannot leak through. One that advertises no roles by
mistake is treated as account-only, and every state it reports with a membership fails
fitsCapabilities(), which a provider checks before rendering it.
Operations, search, removal, metadata and receipts
Everything above about roles, editable, allowed_edits, provisioning and account-only
applications applies to every operation.
| Operation | Request fields besides operation |
Answer fields besides the envelope |
|---|---|---|
capabilities |
none | controls |
subjects |
limit, cursor, query (each optional) |
subjects (entries may add metadata), next_cursor |
workspaces |
limit, cursor, query (each optional) |
workspaces, next_cursor |
read |
subject |
a state |
update |
subject, expected_revision, access, display_name (provisioning only), operation_id |
a state |
remove |
subject, expected_revision, operation_id |
a state |
receipt |
operation_id |
operation_id, status, and when known response_status, response |
A state is subject, provisioned, revision, access, allowed_edits, optionally with the
metadata below. allowed_edits.remove is a required boolean: whether a
remove by this actor would succeed now, a no-op included. It is false whenever the removal would
be refused (a protected membership, an administrator flag the actor may not change, the
last-administrator rule, no permission), and always false for an unprovisioned subject. The
contract refuses a state offering remove while it reports a membership editable: false or an
administrator flag the actor may not change; the other reasons only the application knows.
Search. subjects and workspaces take an optional query: 2 to 100 characters of valid
UTF-8 without control characters (an absent query, never null, means none). The application
matches it as a case-insensitive substring of the label and, where it stores one, the email. It is
always limited to what the actor may see, exactly like the unfiltered listing, with the same cursor
pagination and page bounds. A search must not reveal anything outside that scope, by an entry or
by a cursor: a search that matches only what the actor may not see answers an empty page with
next_cursor: null. DelegatedCursor::encode($actor, $operation, $after, $query) binds a cursor
to its search, and after() refuses it with another one (invalid_cursor, 422).
Metadata. Read-only observations, never authorization and never part of the revision:
- a state may carry
provisioned_at,first_sign_in_atandlast_seen_at, each an ISO-8601 date and time with seconds and an explicit offset (2026-10-10T12:00:00Z,…+02:00, fractions allowed) ornull, where the application knows them. An unprovisioned state carries none, or onlynulls. Eachsubjects[]listing entry may carry the same three fields, held to the same shape, so a provider can show them in a list without reading every subject; - each
capabilities.controls.workspace_roles[]entry may carry adescription(1 to 1024 bytes). Omit it rather than sendnull.
Removal effects. A removal can take more with it than the memberships themselves: removing a staff member may also drop their project assignments. An application discloses that so the provider can show it before the actor confirms:
"access": { "application_admin": false, "workspaces": [ { "id": "w2", "role": "member", "editable": true, "removal_effects": [ { "kind": "project_assignments", "label": "Project assignments", "count": 2 } ] } ], "removal_notice": "Client portal access is unchanged." }
removal_effectsis optional on a reported membership, and only on an editable one: what removing it, or anupdatedropping it, also removes. 1 to 8 entries of exactly{kind, label, count}:kindmatches[a-z][a-z0-9_]{0,63}and is unique within the membership,labelis 1 to 255 bytes of plain text,countan integer of at least 1. Nothing to disclose is the field omitted, never[]or a zero count. It is never sent back in anupdate.countis exact: the number of records the removal takes, in the unit the label names (assignment rows, say, not the distinct projects they point at), never an estimate. Omitting an effect means it does not happen, never that it could not be counted; a membership whose effects cannot be counted is not offered as editable.- One state carries at most 100 effect entries across its memberships
(
MAX_REMOVAL_EFFECTS_PER_STATE), so every valid state fitsMAX_RESPONSE_BYTEShowever its strings escape. A state that would need more is never truncated and never drops an effect to fit: the application offers fewer of those memberships as editable. removal_noticeis optional in a provisioned state'saccess(read,update,remove): up to 500 bytes of plain text giving context for the confirmation, such as what a removal leaves alone. It is never a disclosure: it cannot widen what a removal may take, which is only what the memberships disclose asremoval_effects, so an application never announces a further deletion in it. An unprovisioned state has noaccessto carry it, and it is never sent back in anupdate.- Plain text means valid UTF-8 with something visible in it (not only spaces, including no-break and
zero-width ones, default-ignorable characters such as variation selectors, or combining marks with
nothing to mark), without control characters, line or paragraph separators, or bidirectional
overrides and isolates. Joiners and variation selectors inside visible text are fine. A provider
escapes it, never renders it as markup, and isolates its direction (
<bdi dir="auto">in HTML): text in one direction beside another can reorder without any control character. - A
labelnames a category on its own ("Project assignments"). A provider shows it beside its count (Project assignments: 2) and never inflects it into a sentence. - The revision covers what the effects stand for, not only their counts. A confirmation shown
against "2 project assignments" must fail with
revision_conflictif either assignment changes before the write commits, even when the count is still 2. Read the effects and the revision from one snapshot (one transaction, or under the locks the write takes), and make every path that changes what an effect stands for (assigning, reassigning or deleting a project, not only the access screens) take part in the same locking or versioning. When what a removal takes changes with a deploy (cascading something new, or no longer), the revisions change with it: put a version of the cascade rules in them, so a confirmation made under the old rules is refused under the new ones.assertDelegatedRemovalEffectsAreInTheRevision($actor, $target, $swap, $disclosed)checks stale removal and membership-drop writes on a removable target with at least one disclosing membership; each refusal leaves access and the records read by$discloseduntouched.assertDelegatedWriteTakesExactlyWhatItDisclosed($actor, $target, $write, $disclosed, $expected)checks one fresh write per separately seeded target:remove,dropof the first disclosing membership, orrolechanging it to another advertised role the actor may grant. It compares the exact resulting records with$expected; a role change leaves them unchanged. One disclosing membership suffices for each helper. They run in one process, so they cannot race the database; an application's own adapter tests should also insert or reassign a dependent record concurrently with a removal. - Effects are a disclosure, not a promise the provider enforces. The application performs and audits the cascade (see Removal below for what it may take); the revision is what keeps the confirmation honest.
- Per membership, never summed. A provider shows each membership's effects with that membership,
for a
removeand for anupdatedropping it alike. It does not add them up across memberships: a kind is unique only within one membership, and two memberships may count overlapping records or label the same kind differently. An effect that happens only when several memberships go together (the last of a person's assignments for one client, say) belongs to none of them, so the application does not offer those memberships as editable. - Only removal is described. Changing a membership's role, or clearing the administrator flag,
takes nothing else with it. An application where such a change would cascade refuses it through
this contract (
invalid_request), or does not offer the membership or the flag as editable. - A disclosure grants nothing. The application authorizes the whole cascade for this actor, as it does the membership. Records the actor may not see one by one still count in an effect's total, under a label that names them generically; they are never left out. Where even a count would say too much, the membership is not offered as editable.
- Upgrade order. Both fields sit inside objects whose keys a provider on an earlier release checks
exactly, so it refuses a state carrying either as
invalid_responserather than dropping the disclosure: it fails closed. That protects only until the provider takes this release. Upgrading the provider means its confirmations show the effects, for aremoveand for anupdatedropping a membership, not only that its validator accepts them; an application emits them only after that is deployed. The application changes its cascade-rules version in the same deploy that starts disclosing, so a confirmation shown before (in a page left open, say) is refused as a revision conflict rather than carried out under rules it never showed.
Operation ids. update and remove carry operation_id: 32 to 64 characters of
[A-Za-z0-9_-], chosen by the provider once per user action and kept across every retry of it
(DelegatedContract::operationId() makes one). It is distinct from the assertion's jti, which
stays single-use per HTTP request; the endpoint refuses an operation_id equal to the jti.
Removal. remove takes away the subject's access to this application as far as the actor
manages it, together with whatever it disclosed as removal_effects, and nothing else:
- It removes every membership in the actor's projection and the application administrator flag. Memberships in workspaces the actor cannot see are outside it and survive.
- If any membership in the projection is protected (reported
editable: false), the administrator flag is set and the actor may not change it, or removal would break the last-administrator rule, it is refused (protected_membership,not_authorizedorinvalid_request) and changes nothing. Never a partial removal. - The account and its history stay. The answer is the new state, with
provisioned: true, no administrator flag, no memberships in the projection andallowed_edits.remove: true(removing again is a no-op); the endpoint refuses to send any other. - The revision is compared as for an update (
revision_conflict, 409). Removing a subject with nothing to remove changes nothing and answers the same revision. An unprovisioned subject is refused asnot_provisioned(404). - A removed membership may take records that exist only because of it (a staff member's project
assignments in that workspace): exactly what the membership disclosed as
removal_effects, never records it did not disclose, and the same whether the membership goes byremoveor by anupdatedropping it. A membership that would take something with it and cannot say so (more than eight kinds, say) must not be offered as editable. - Suspending, revoking admission and deleting the account or data that outlives the membership are separate operations, not this one.
Receipts. The endpoint, not the adapter, keeps a receipt for every update and remove:
- The write is claimed before the adapter runs, keyed by application and a SHA-256 of
operation_id(ids are case-sensitive, whatever the column collation), with a conflict-safe insert that never aborts a surrounding transaction (on SQL Server, an insert in a savepoint). A repeat of the same request (same actor, same canonical payload) is answered with the stored status and body, byte for byte, without calling the adapter. The sameoperation_idon a different request, or from another actor, is refused asinvalid_request(422). One that is still being decided is answeredoperation_in_progress(503): its outcome is not known yet. - A claim whose answer never arrives (the request died mid-write) blocks repeats for ten minutes
(
DatabaseReceiptStore::PENDING_LEASE_SECONDS), andreceiptreports itunknown. After that a repeat of the same request (same actor and payload) claims it again and runs the adapter, whose revision check decides afresh whether the first attempt changed anything; its 30 days of retention start again then. A request that outlived its lease can no longer store or release over the claim that replaced it. - A success and every refusal the adapter makes (4xx) are stored. A 5xx from the adapter, an exception, or an answer outside the contract stores nothing, since nothing vouches for what happened; a later attempt is decided afresh, against the revision.
receiptanswers{operation_id, status: "known", response_status, response}, whereresponseis the stored body (a state on 200,{error}on a refusal), or{operation_id, status: "unknown"}for one never stored, still pending or abandoned, pruned, or another actor's. It never reaches the adapter and is answered whether or not writes are enabled.- Receipts are kept for 30 days.
- A stored answer is what the write did then, not what a write would do now: its
removal_effectsmay be stale. A provider shows it as an outcome and never fills a new confirmation from it; it reads afresh before confirming another write.
A provider that gets an uncertain answer to a write (a 5xx, a timeout or a transport error) asks for the receipt once and shows the real outcome; if it is still unknown, it says so. It never retries a write automatically.
Provider-side builders
The provider builds and checks every message with the same DelegatedContract:
$contract = new DelegatedContract; $operationId = DelegatedContract::operationId(); // once per user action $write = $contract->request($application, [ 'operation' => 'remove', 'subject' => $subject, 'expected_revision' => $revision, 'operation_id' => $operationId, ], DelegatedContract::VERSION_3); $state = $contract->response($decoded, $application, 'remove', $subject, DelegatedContract::VERSION_3); // After an uncertain write: ask once, then check the answer is about that write. $ask = $contract->request($application, ['operation' => 'receipt', 'operation_id' => $operationId], DelegatedContract::VERSION_3); $receipt = $contract->receipt($decodedReceipt, $application, $write);
receipt() checks the shape, the operation_id echo, and that a stored success is for the same
operation and subject. response() with 'receipt' checks the shape alone. validQuery() and
validOperationId() check single values. fitsCapabilities() covers a remove answer as it does
read and update.
Serving the endpoint
The package serves POST /application-access for contract version 3. The application supplies
only what is its own: an adapter deciding who may manage access and what they may see and change.
-
Implement
BWH\Auth\OAuth\DelegatedAccess\ApplicationAccessAdapterand bind it in a service provider'sregister(). Binding it is the opt-in: without a binding there is no route.$this->app->bind(ApplicationAccessAdapter::class, MyApplicationAccessAdapter::class);
-
Publish and apply the delegated access migrations (
bherila-auth-delegated-access-migrations, above): the nonce table and the operation receipts table (bherila_auth_delegated_receipts). A write is refused withreceipt_storage_unavailable(503) until the receipts table exists. The receipts migration creates its table onDELEGATED_ACCESS_RECEIPT_CONNECTION(below), so set that before migrating. -
Configure the deployment:
Variable Meaning DELEGATED_ACCESS_ENABLEDtrueto answer; the route answers 404 otherwiseDELEGATED_ACCESS_WRITES_ENABLEDtrueto acceptupdateandremove; defaultfalse, so a new deployment is read-onlyDELEGATED_ACCESS_ISSUERthe provider's exact HTTPS issuer; must be the sign-in provider ( oauth_client.base_url)DELEGATED_ACCESS_ENDPOINTthis endpoint's exact HTTPS URL, as the provider is configured to call it DELEGATED_ACCESS_APPLICATIONthis application's key in the provider's registry DELEGATED_ACCESS_PUBLIC_KEYSthe provider's integration public keys, key-id|/path/to/public.pem, comma-separatedOAUTH_PROVIDERmust be set explicitly; it names the issuer local identity bindings are stored under DELEGATED_ACCESS_NONCE_CONNECTIONoptional; the nonce table's connection, default connection otherwise DELEGATED_ACCESS_RECEIPT_CONNECTIONoptional; the receipts table's connection, the nonce connection otherwise. Durable and shared by every worker bherila-auth.delegated_access.path(default/application-access) andper_minute(default 120 per client IP) are config-only. The limit is applied inside the controller after the enabled check, so a disabled endpoint answers 404 and never 429. If any listed key file is unreadable,OAUTH_PROVIDERis unset or disagrees withoauth_client.provider, orDELEGATED_ACCESS_ISSUERis not the sign-in provider'soauth_client.base_url(a trailing slash aside), every request is refused withinvalid_verifier_configuration. That last check keeps a subject in the namespace it was issued in: the adapter resolves it underbindingIssuer(), which is only right if the provider asserting it is the one people sign in with. To rotate, list both public keys, switch the provider to the new key id, then remove the old one. -
Schedule
bherila-auth:prune-delegated-noncesdaily. It deletes expired nonces only, and receipts older than 30 days when the receipts table is installed.
The controller refuses an oversize body (MAX_REQUEST_BYTES) and a missing bearer before
verification. It verifies the assertion and consumes its nonce before parsing the body, then
requires version 3 and this application's key. The adapter's handle($actorSubject, $payload)
receives operation plus that operation's fields, and returns that operation's response fields:
capabilities, subjects, workspaces (both with an optional query), read, update and
remove. The controller adds contract_version, application and operation, validates the whole
answer (including the subject echo for read, update and remove), and sends it with
Cache-Control: no-store. A DelegatedAccessException thrown by the adapter is sent as its
outcome and status. An answer outside the contract is reported and becomes internal_error (500),
never sent. Until DELEGATED_ACCESS_WRITES_ENABLED is set, update and remove are refused with
not_authorized (403) after verification and before the adapter, so an application can stop
accepting changes without touching the provider. Writes go through the receipts described under
contract version 3, so the adapter never sees the same operation_id twice;
receipt is answered by the package and never reaches the adapter.
While the adapter runs, the container holds a DelegatedRequestContext with the verified
issuer, subject, application, jti, operation and, for a write, operationId. Resolve it
(or inject it into an adapter bound with bind()) to record jti and operationId with the
application's own audit, correlating the provider's attempt and result records. jti is a
single-use nonce: never key a retry on it; operationId is the same on every attempt of one
action. The binding is removed when the call returns.
Laravel may read a JSON body in global middleware before any controller runs, so bound this
route's body to the same 64 KiB at the web server where you can. Nothing in front may rewrite the
body or strip Authorization: the assertion is bound to the exact bytes.
Helpers for adapters:
DelegatedAccessSettings::bindingIssuer()is the provider name to resolve actors and targets under, the same one sign-in binds.DelegatedCursorencodes an encrypted keyset cursor bound to the actor, the operation and the search:encode($actor, $operation, $lastKeyShown, $payload['query'] ?? null), andafter($actor, $operation, $payload)returns 0 for a first page and refuses a foreign, tampered or other-search cursor withinvalid_cursor(DelegatedRefusal::INVALID_CURSOR). It stays within the contract's 512-byte bound for any subject and query length.BWH\Auth\OAuth\PendingAccount::email($provider, $subject)andname($label, $subject)give a provisioned account's placeholder contact details until first sign-in. The address is under.invalidand is never a linking key.
An account-only adapter answers capabilities with workspace_roles: [], workspaces with
{"workspaces": [], "next_cursor": null} (searched or not), and every state with workspaces: []
in access and workspaces: false in allowed_edits. Its remove clears the administrator flag
and keeps the account, and allowed_edits.remove is false for the actor themselves and the last
administrator. It authorizes each of those operations exactly as a
workspace application does. The endpoint validates each answer on its own and cannot hold it to
the capabilities; the conformance assertions below do.
What remains the adapter's: match the verified actor to a local account through its binding and
refuse (not_authorized, 403) unless it is active and may manage access. Show only what that actor
may manage. Compare revisions under the same locks the changes take (revision_conflict, 409).
A revision is of the subject as this actor sees it. It changes when the subject's records change and
when the actor's reach over them does (a workspace it gains or loses, whether it may change the
administrator flag), so two actors may be given different revisions for one subject. Otherwise an
actor that gains a workspace after reading would remove a membership it never saw, or drop it by
omission when resubmitting the update it read.
Provision only an unbound subject, bound to bindingIssuer() and the exact subject, never by
adopting a row found by address. Keep the application's own last-administrator and audit rules.
Update semantics (normative)
The provider holds no authority of its own; whatever the adapter does not enforce is not enforced.
-
Authorize every operation,
capabilitiesand the listings included. An actor who may not manage access learns nothing, not even the roles. -
An update replaces the actor's projection, never the subject's whole access. Memberships in workspaces the actor cannot see are outside the request and survive it unchanged.
-
editableandallowed_editsdescribe rules; they do not delegate them. A membership reported as not editable is refused whether the update changes its role or omits it. A change toapplication_adminis refused unless both the capabilities and this read allow it. -
Roles are checked against what this actor may grant, not only against what was advertised. Never infer a hierarchy from the order roles are advertised in.
-
Change through the application's own domain service, under its locks, and compare the revision under those locks. Validation, revision and every rule above are decided on the rows the change takes.
-
A refusal changes nothing and uses one of these outcomes (
BWH\Auth\OAuth\DelegatedAccess\DelegatedRefusal):Outcome Status When not_authorized403 the actor may not manage access, or may not see or change this target or workspace protected_membership403 the update changes or omits a membership reported as not editable role_not_grantable403 a role this actor may not grant, or one never advertised not_provisioned404 a read of an unknown subject, when not reported as unprovisioned revision_conflict409 a stale revision, or provisioning a subject already bound invalid_request422 well formed, but not something this application can apply A provider acts on the status when it does not know the outcome, so the newer outcomes reuse statuses it already handles. Never refuse with a 5xx: for a write the provider must then assume the change may have happened.
-
An account-only application takes no memberships. An update naming one is refused, and the application administrator flag is the whole of what an ordinary update changes. Its own rules, such as refusing self-demotion or demoting the last administrator, are reported as
allowed_edits.application_admin: falsefor that target and still enforced. -
A search is the unfiltered listing, filtered. It finds nothing the actor could not list, and says nothing about the rest: no entry, no cursor to a page that turns out empty.
-
A removal is all of the actor's projection or nothing. It follows the removal rules: unseen memberships and the account survive, anything protected refuses the whole removal, and removing nothing keeps the revision.
BWH\Auth\Testing\AssertsDelegatedAccessAdapter checks rules 1 to 9 against an application's real
adapter and tables, and the receipts through the real endpoint. Implement delegatedAccessTruth($subject) by reading the tables directly and
delegatedAccessManager(), seed a target with an editable membership, a protected one and one
outside the manager's view, then call:
$this->assertDelegatedActorRefusedEverywhere($stranger, $target, $workspaceId); $this->assertDelegatedProtectedMembershipsHold($manager, $target); $this->assertDelegatedApplicationAdminFollowsAllowedEdits($manager, $target); $this->assertDelegatedStaleRevisionRefused($manager, $target); $this->assertDelegatedUnadvertisedRoleRefused($manager, $target); $this->assertDelegatedUpdateKeepsUnseenMemberships($manager, $target);
Each refused attempt must leave delegatedAccessTruth() exactly as it was.
For version 3, also seed a subject and a workspace outside the manager's view whose labels match a
search, a target the manager may remove entirely (with a membership it cannot see), and known
metadata. For the revision's reach, seed a removable target with a membership in a workspace the
manager does not manage yet, and a $widen closure that grants the manager that workspace.
If the adapter discloses removal effects, also seed a removable $effectsTarget with one disclosing
membership, a $swap that replaces a dependent record without changing any count, and a
$disclosedEffects reader of its own records. Seed separate $removeTarget, $dropTarget and
$roleTarget for fresh writes, each with at least one disclosing membership. Their record readers
include records outside the actor's projection that must survive. $afterRemove and $afterDrop
are the exact expected records after those writes; $beforeRole is the unchanged record set, and
$roleTarget has another advertised role the actor may grant. Then:
$this->assertDelegatedSearchStaysInScope($manager, 'subjects', 'Example', 'OnlyOutsideTheView'); $this->assertDelegatedSearchStaysInScope($manager, 'workspaces', 'Example', 'OnlyOutsideTheView'); $this->assertDelegatedRemoveRefusedWithoutPartialChange($manager, $target); // has a protected membership $this->assertDelegatedRemoveStripsOnlyTheManagedProjection($manager, $removable); $this->assertDelegatedMetadataIsWellFormed($manager, $removable); $this->assertDelegatedReceiptsReplayThroughTheEndpoint($manager, $target); $this->assertDelegatedRevisionCoversTheActorsReach($manager, $colleague, $widen); // widens the manager // For adapters disclosing removal_effects: $this->assertDelegatedRemovalEffectsAreInTheRevision($manager, $effectsTarget, $swap, $disclosedEffects); $this->assertDelegatedWriteTakesExactlyWhatItDisclosed($manager, $removeTarget, 'remove', $disclosedRemove, $afterRemove); $this->assertDelegatedWriteTakesExactlyWhatItDisclosed($manager, $dropTarget, 'drop', $disclosedDrop, $afterDrop); $this->assertDelegatedWriteTakesExactlyWhatItDisclosed($manager, $roleTarget, 'role', $disclosedRole, $beforeRole);
assertDelegatedReceiptsReplayThroughTheEndpoint() posts signed requests to the real route. It
needs the adapter bound in a service provider (so the route exists) and the receipts migration in
the test database. It configures the endpoint for the test with a key of its own and an in-memory
nonce store, and keeps the application's oauth_client.provider, which the adapter resolves
bindings under. delegatedAccessEndpoint($actor, $input) sends any other request the same way.
For an account-only application, delegatedAccessTruth() returns workspaces: [] and the same
calls apply. Omit the workspace argument to assertDelegatedActorRefusedEverywhere(). The
membership checks return without checking anything (they never skip, which would end the test
method), assertDelegatedUnadvertisedRoleRefused() checks that any membership is refused, and every
answer is checked with fitsCapabilities(). Exercise the administrator flag with a target the actor
may not change: the actor themselves where self-demotion is refused, or the last administrator.
$this->assertDelegatedActorRefusedEverywhere($ordinaryAccount, $target); $this->assertDelegatedApplicationAdminFollowsAllowedEdits($administrator, $administrator); $this->assertDelegatedStaleRevisionRefused($administrator, $target); $this->assertDelegatedUnadvertisedRoleRefused($administrator, $target); $this->assertDelegatedUpdateKeepsUnseenMemberships($administrator, $target); $this->assertDelegatedSearchStaysInScope($administrator, 'subjects', 'Example', 'OnlyOutsideTheView'); $this->assertDelegatedRemoveRefusedWithoutPartialChange($administrator, $administrator); $this->assertDelegatedRemoveStripsOnlyTheManagedProjection($administrator, $anotherAdministrator); $this->assertDelegatedReceiptsReplayThroughTheEndpoint($administrator, $target);
The workspaces search check only confirms an empty page for it.