Skip to content

Extension contracts

Each of the package’s extension points is a container-bound interface with a default implementation. Rebind any of them from a service provider’s register() (or boot()) method to replace the behavior without touching a caller.

Bambamboole\LaravelOidc\Server\Shared\Realms\IssuerResolver returns the issuer identifier every protocol surface builds on: the iss claim of id_tokens, access tokens and logout tokens, the issuer and endpoint URLs in the discovery document, the audience the oidc guard accepts, and RFC 9728 resource metadata.

interface IssuerResolver
{
public function url(): string;
}

The default Realms\RealmIssuerResolver returns oidc.issuer, falling back to app.url, with any trailing slash trimmed, and appends /realms/{realm} when oidc.routes.realms is path. It is bound as a singleton, so a resolver must derive the issuer from the current request on every call rather than remember it — under Octane one instance serves many requests.

$this->app->singleton(
\Bambamboole\LaravelOidc\Server\Shared\Realms\IssuerResolver::class,
PerHostIssuerResolver::class,
);

Every issuer URL the package emits or validates against goes through this contract, so a rebind changes them consistently. It does not move any route — endpoint paths still come from the routes file, and the discovery document composes them onto the resolved issuer origin.

Bambamboole\LaravelOidc\Server\Scopes\Contracts\ScopeRepository is the catalog of scopes the provider understands. Every lookup is bound to the resources the request asks for: a scope only exists under the audiences that own it. An empty $audiences means the realm’s default audience, its issuer URL — the same convention the token minter follows.

interface ScopeRepository
{
/**
* @param list<string> $audiences
* @return Collection<int, Scope>
*/
public function all(array $audiences = []): Collection;
/** @param list<string> $audiences */
public function find(string $identifier, array $audiences = []): ?Scope;
/**
* The last word on what a token gets: `$requested` is already limited to
* known scopes the client is assigned, its default scopes included.
*
* @param Scope[] $requested
* @param list<string> $audiences
* @return Scope[]
*/
public function finalize(array $requested, string $grantType, ?Client $client, ?string $userIdentifier = null, array $audiences = []): array;
}

The default ConfiguredScopeRepository merges scopes in order: first, the configured catalog (oidc.scopes) reduced to what the requested resources own; second, the scopes those resources declare in oidc.resources; third, the built-in OIDC scopes (openid, profile, email), which hold under every audience. The first occurrence of a scope id wins. Its finalize() filters out scopes no requested resource owns. (See Scopes & claims for a deeper look at the merge strategy.) Bind your own to change the catalog:

$this->app->singleton(
\Bambamboole\LaravelOidc\Server\Scopes\Contracts\ScopeRepository::class,
MyScopeRepository::class,
);

Bambamboole\LaravelOidc\Server\Scopes\Contracts\ClaimsResolver turns a ClaimsRequest into the claims to emit.

interface ClaimsResolver
{
/** @return array<string, mixed> */
public function resolve(ClaimsRequest $request): array;
}

The request carries the user, the requesting client (clientId), the granted scopes, and the audience being built (ClaimsAudience::IdToken or ::Userinfo), so a resolver can vary claims per client and per surface. ClaimSet remains available for the common scope-gated case — see Scopes & claims for both shapes. The default is StandardClaimsResolver. Bind your own:

$this->app->singleton(
\Bambamboole\LaravelOidc\Server\Scopes\Contracts\ClaimsResolver::class,
AppClaimsResolver::class,
);

Bambamboole\LaravelOidc\Server\Tokens\Contracts\ExchangePolicy authorizes every RFC 8693 token exchange (and every IssueScopedToken action call).

interface ExchangePolicy
{
public function authorize(ExchangeRequest $request): ExchangeGrantResult;
}

The default AllowlistExchangePolicy enforces audience reciprocity, the target allowlist, scope narrowing, same-subject, and a lifetime cap — see Token exchange for the full rules. authorize() must return an ExchangeGrantResult or throw an ExchangeDeniedException carrying the RFC error code (invalidGrant(), accessDenied(), invalidTarget(), invalidScope()); the token endpoint renders it. Rebind it to add tenant checks or a different allowlist source:

$this->app->singleton(
\Bambamboole\LaravelOidc\Server\Tokens\Contracts\ExchangePolicy::class,
TenantScopedExchangePolicy::class,
);

Bambamboole\LaravelOidc\Server\Shared\Sessions\SessionTokenProvider owns the server-side session root token used by the browser-fetch flow.

interface SessionTokenProvider
{
public function currentToken(): ?string;
public function establish(Authenticatable $user): void;
public function forget(): void;
}

The default SessionTokenIssuer mints the root token on the Login event, re-mints it lazily as it nears expiry, and revokes + clears it on the Logout event. Rebind it to source the root token elsewhere (e.g. an external SSO exchange):

$this->app->singleton(
\Bambamboole\LaravelOidc\Server\Shared\Sessions\SessionTokenProvider::class,
MyExternalSsoTokenProvider::class,
);

Bambamboole\LaravelOidc\Server\Shared\Authentication\DeviceRecognizer decides whether the current request comes from a device already known for the user — it backs the LoginEvent::isNewDevice() signal in the post-login pipeline.

interface DeviceRecognizer
{
public function isKnown(Authenticatable $user, Request $request): bool;
}

The default NullDeviceRecognizer returns true for every request (every device is treated as known), so isNewDevice() is effectively always false until a real device-recognition release ships. There is no device tracking behind it yet. Bind your own to add it:

$this->app->singleton(
\Bambamboole\LaravelOidc\Server\Shared\Authentication\DeviceRecognizer::class,
MyDeviceRecognizer::class,
);

Implement it to hold a login until the user has done something your application cares about. See Required actions for the contract, the registry and the screen conventions.

Every use case the package’s controllers, commands and jobs perform is an invokable class under <Domain>\Actions. Controllers validate the request, call the action and shape the response; the action owns the behavior, including audit events. Actions are resolved from the container, so binding your own class to an action’s name replaces it for every caller:

$this->app->bind(RegisterClient::class, App\Oidc\RegisterClientWithApproval::class);
Action Domain Called by
RegisterUser, AuthenticateWithPassword, SendPasswordResetLink, ResetPassword, UpdatePassword, ConfirmPassword, SendEmailVerification Authentication the login, registration, password and verification endpoints
EnrollFactor, ConfirmFactorEnrollment, RevokeFactor, VerifyFactorChallenge Credentials the factor enrollment and two-factor challenge endpoints
LinkSocialAccount, UnlinkSocialAccount Brokering the social callback and linked-account endpoints
RegisterClient, ProvisionFirstPartyClient Clients dynamic client registration, oidc:client, oidc:install-self
IssueScopedToken Tokens your application, to mint browser tokens
EndSession Sessions the end-session endpoint
CompleteAuthorization Consents the consent approve and deny endpoints

The three contracts Shared\Users\CreateUser, Shared\Users\ResetUserPassword and Shared\Brokering\CreateUserFromSocialAccount have no default implementation; see Auth overview.