Realms
The package serves several realms — isolated sets of clients, tokens, sessions and signing keys — from one deployment. Each realm is its own OpenID Provider: it has its own issuer, its own discovery document and its own signing keys.
It does not define what a realm is. There is no realm table here, no foreign key, and no administration. A realm is an opaque string; its name, branding and administrators belong to your application, exactly as the user model does.
Addressing
Section titled “Addressing”Where realms appear in URLs is oidc.routes.realms:
-
single(the default) serves the realm named byoidc.realmfrom the application root. There is no realm segment, and the issuer is the bare origin —oidc.issuerorapp.url:https://id.example.com/.well-known/openid-configurationhttps://id.example.com/oauth/authorizehttps://id.example.com/auth/loginissuer = https://id.example.com -
pathserves every realm below/realms/{realm}. Each realm is its own OpenID Provider with its own issuer; the origin comes fromoidc.issuerand the realm supplies the path:https://id.example.com/realms/acme/.well-known/openid-configurationhttps://id.example.com/realms/acme/oauth/authorizehttps://id.example.com/realms/acme/auth/loginissuer = https://id.example.com/realms/acmeA relying party configured against
https://id.example.com/realms/acmediscovers, verifies and logs in entirely within one realm. More than one realm per deployment requires this ordomain. -
domainserves every realm from its own host. Each realm is its own origin, so every endpoint keeps the path it has insingleand the issuer is the host itself:https://acme.id.example.com/.well-known/openid-configurationhttps://acme.id.example.com/oauth/authorizehttps://acme.id.example.com/auth/loginissuer = https://acme.id.example.comMap each host to a realm in
oidc.routes.domains:'routes' => ['realms' => 'domain','domains' => ['acme.id.example.com' => 'acme','login.globex.example' => 'globex',],],An application with a realm model skips the map: it resolves the host in its own
RealmRepositoryand names it inRealm::host(), which is what lets a customer bring their own domain. A host no realm is served from answers 404.The issuer is the realm’s host with the scheme and port of
oidc.issuer, whichever host the request, queued job or console command runs on. Every absolute URL to a route behindResolveRealm— the package’s own and any of yours — points at the current realm’s host, so a password reset sent from an admin console on another host links into the realm it belongs to.Because each realm is a separate origin, the browser isolates its cookies, so this mode needs no per-realm session cookie the way
pathdoes.
The mode is fixed when the routes are registered; the route names are the same in all three.
Metadata that is not prefixed
Section titled “Metadata that is not prefixed”RFC 8414 and RFC 9728 build their metadata URLs by inserting the well-known segment ahead of
the issuer’s path rather than appending it. In single and domain mode the issuer has no path,
so both documents sit directly below /.well-known/. In path mode the realm follows the
well-known segment:
https://id.example.com/.well-known/oauth-authorization-server/realms/acmehttps://id.example.com/.well-known/oauth-protected-resource/realms/acme/mcpOpenID Connect Discovery appends instead, which is why
/realms/acme/.well-known/openid-configuration is the prefixed one. Both serve the same
document.
Resolving the realm
Section titled “Resolving the realm”Everything hangs off two contracts in Bambamboole\LaravelOidc\Server\Shared\Realms:
interface RealmResolver{ public function current(): Realm;}
interface RealmRepository{ public function find(string $id): ?Realm;
public function findByDomain(string $host): ?Realm;}In single and path mode the RouteRealmResolver reads the {realm} route parameter, asks the
bound RealmRepository for it, and answers 404 for an identifier the repository does not know. In
domain mode the DomainRealmResolver asks findByDomain() for the request host instead, and a
host no realm is served from is a 404 just the same. Without a realm to read — every request in
single mode, and console commands or queued jobs in any mode — resolution falls back to the
context and then to config('oidc.realm'). The
default ConfiguredRealmRepository accepts every identifier, serves it with the configured
settings, and maps hosts through oidc.routes.domains, so a deployment that only scopes data per
tenant needs no code.
An application with a realm model binds the repository:
use Bambamboole\LaravelOidc\Server\Shared\Realms\RealmRepository;
$this->app->singleton(RealmRepository::class, EloquentRealmRepository::class);Both contracts are singletons. A resolver must derive the realm from the current request on
every call rather than remember it — under Octane one instance serves many requests. Resolution
therefore runs many times per request, and in domain mode so does findByDomain(). Memoize inside the repository, keyed by the value looked up, rather than caching a realm on the
resolver.
The shipped resolvers read the realm from the URL — the {realm} path segment, or the single
configured realm. The cacheable documents (jwks.json, discovery, RFC 8414 and RFC 9728 metadata)
therefore carry Cache-Control: public safely, because a shared cache keys on the URI. A resolver
that derives the realm from anything outside the URL, a request header say, must add a matching
Vary to those responses, or a proxy will serve one realm’s keys to another.
Realm settings
Section titled “Realm settings”Realm is the contract your model implements. Beyond its identifier and, in domain routing,
its host(), it exposes ten typed
settings objects; every behavior that may differ between tenants reads from them instead of
from config('oidc.*'):
| Method | Settings object | Drives |
|---|---|---|
tokens() |
TokenSettings |
access, id, client-credentials and refresh token lifetimes |
resources() |
ResourceSettings |
the resource servers the realm serves besides itself: audiences a client may request and RFC 9728 metadata |
sessions() |
SessionSettings |
SSO session absolute lifetime; session root token TTL, refresh skew and scopes; the provider session cookie name in path mode |
login() |
LoginSettings |
username field, home URL, login route, logout redirect, acr values |
authentication() |
AuthenticationSettings |
the login methods the realm accepts, how hard it insists on a second factor, whether an unverified email address blocks the login |
credentials() |
CredentialSettings |
challengeable factor providers, TOTP secret length and window, recovery code count, the password policy |
brokering() |
BrokeringSettings |
upstream identity providers, link-by-verified-email, auto-provisioning |
scopes() |
ScopeSettings |
the scope catalog and the advertised claims_supported |
clients() |
ClientSettings |
dynamic registration and its redirect rules, the default and optional scopes new clients are assigned, token exchange, the first-party and trusted clients |
keys() |
KeySettings |
RSA key size for generated signing keys |
The settings objects live in Bambamboole\LaravelOidc\Server\Shared\Realms\Settings; each is a
final readonly value object with a fromConfig() constructor. ConfiguredRealm implements
the whole contract from config('oidc.*'), so a model can delegate what it does not store:
use Bambamboole\LaravelOidc\Server\Realms\ConfiguredRealm;use Bambamboole\LaravelOidc\Server\Shared\Realms\Realm;use Bambamboole\LaravelOidc\Server\Shared\Realms\Settings\TokenSettings;
class Tenant extends Model implements Realm{ public function identifier(): string { return $this->slug; }
public function host(): ?string { return $this->domain; }
public function tokens(): TokenSettings { return new TokenSettings( accessTokenLifetime: $this->access_token_lifetime, refreshTokenLifetime: $this->refresh_token_lifetime, ); }
// sessions(), login(), … delegate to (new ConfiguredRealm($this->slug))->sessions() etc.}What stays in config/oidc.php is deployment-wide by nature: the issuer origin, guard and
provider names, the signing key store and key material, the audit sink, route middleware,
the advertised protected resources, and the install-time first-party provisioning values.
ResolveRealm
Section titled “ResolveRealm”The ResolveRealm middleware runs on every package route: it records the realm on the request,
registers it as the default {realm} for URL generation, publishes it to the context, and — in
path mode — gives the provider its own session cookie (see Sessions).
It also removes {realm} from the matched route’s parameters. That is load-bearing rather than
cosmetic: Laravel hands route parameters to controller methods positionally, so a leading realm
would shift every other argument along by one.
Realms outside a request
Section titled “Realms outside a request”There is no request to read a realm from in a queued job, so the realm travels with the job
instead. ResolveRealm publishes the resolved realm to
Laravel’s context, which Laravel serializes into every job
dispatched while serving the request and restores before the job runs:
Context::get('oidc.realm'); // 'acme', in the request and in every job it queuedThe resolver reads it back, so a job dispatched from acme resolves acme on the worker: its
inRealm() queries hit that realm’s rows, IssuerResolver names that realm’s issuer, and the
Keyring signs with that realm’s key. route('oidc.authorize') gets the same treatment — the
package syncs the default {realm} from the context before each job runs, so queued
password-reset and email-verification notifications link back into the realm that sent them.
The constants are on Bambamboole\LaravelOidc\Server\Shared\Context\OidcContext, which also
publishes oidc.client_id — the client of the authorize or token request — for the same reason
and for log correlation.
Code running outside a package route (a console command, or one of your own controllers
in a multi-realm deployment) has no realm of its own and falls back to config('oidc.realm').
Run it in the realm you mean instead:
use Bambamboole\LaravelOidc\Server\Realms\CurrentRealm;
CurrentRealm::runAs('acme', function (): void { $clients->createAuthorizationCodeGrantClient('Portal', ['https://portal.test/callback']); SendWelcomeMail::dispatch($user);});Inside the callback everything that resolves the current realm sees acme, whatever host the
request came in on: new rows are scoped to it, IssuerResolver and the Keyring serve it,
route() links into it, and jobs dispatched inside carry it to the worker. The realm it
interrupted is restored afterwards, so calls nest.
What the package scopes
Section titled “What the package scopes”realm is stored on oidc_clients, oidc_access_tokens, oidc_refresh_tokens,
oidc_auth_codes, oidc_consents, oidc_sessions, oidc_signing_keys,
oidc_authentication_contexts, oidc_password_reset_tokens and oidc_social_accounts. A client_id only has to be unique
within its realm, so the same readable name can exist in several; likewise a social
provider’s user id is unique per realm, so the same upstream identity can be linked to a different
user in each realm. Session participants carry no realm of their own — they belong to a session,
which does.
Scoping is explicit (Model::query()->inRealm()), not a global scope: administration reads across
realms on purpose, and a guard that half the callers disable hides the isolation it claims to
provide.
What your application must scope
Section titled “What your application must scope”Deleting users, clients and realms
Section titled “Deleting users, clients and realms”The package’s rows name users, clients and realms without foreign keys, so deleting one of those
cascades into nothing. Three actions in Bambamboole\LaravelOidc\Server\Purge remove what the
package keeps for each:
| Action | Removes |
|---|---|
PurgeUser($user) |
The user’s tokens, codes, consents, sessions, authentication contexts, reset link, social accounts, password history and second factors, and every client the user registered (with PurgeClient). |
PurgeClient($client) |
The client and every token, code, consent and session participation issued to it. |
PurgeRealm($realm) |
Every row stored under the realm’s identifier, and the participants of its sessions. |
Each runs in a transaction. None of them deletes your own rows, and none tells a relying party that its sessions ended — revoke and notify first when back-channel logout should fire. Password history and second factors belong to a user rather than a realm, so to delete a realm, purge its users before the realm:
DB::transaction(function () use ($realm): void { $realm->users()->each(function (User $user): void { app(PurgeUser::class)($user); $user->delete(); });
app(PurgeRealm::class)($realm->identifier()); $realm->delete();});Sessions
Section titled “Sessions”This is the realm-specific half of the session story; for how the browser session and the OIDC SSO session relate in general, see Sessions.
Only path routing shares a host between realms, so it is the only mode where the package touches
the session cookie at all.
In single mode the provider shares the application’s session: the same cookie, the same
session id. A self-SSO deployment — the application is its own relying party on the same host —
relies on that, because Laravel’s CSRF cookie has one fixed name and two independent sessions on
one path would overwrite each other’s token.
In path mode each realm gets its own session cookie, scoped to /realms/{realm}, so a browser
session in one realm is not sent to another and a login in the provider cannot consume or
regenerate the relying party’s session. The cookie is named {session.cookie}-oidc-{realm} (dots
in the realm id become underscores) unless the realm’s SessionSettings::$cookieName — from
oidc.session.cookie_name by default — names it; the name must differ from the application’s
and consist of letters, digits, underscores or hyphens. The rename lasts for the request and is
restored afterwards. When a provider response redirects out of the realm, the provider’s CSRF
cookie is expired so the application’s own is used again.
In domain mode each realm is served from its own host, so the browser separates the cookies
already and the package leaves the session alone.
In all three modes the realm’s SSO session (oidc_sessions) and its stashed authorize request are
scoped by realm.