firefly/security-oauth2-client is Spring Security's oauth2Login() and oauth2Client() on top of
firefly/security: client registrations spelled exactly as Spring Boot spells them (with presets
for Google, GitHub, Okta, Keycloak and Microsoft Entra, and OIDC discovery for everything else), authorization-code
login with PKCE and a nonce, id-token validation against the provider's JWKS, userinfo mapped to an OidcUser /
OAuth2User principal signed into the same session-persisted SecurityContext form login uses, the framework's
login page listing every provider, OIDC RP-initiated logout, an authorized-client manager for client credentials
and refresh, Http::oauth2Client(), and a whole provider in one class for tests. Every key lives under
firefly.security.oauth2.client.* and defaults to off.
// config/firefly.php
'security' => [
'enabled' => true,
'http' => ['enabled' => true, 'rules' => [['pattern' => '*', 'access' => 'authenticated']]],
'oauth2' => [
'client' => [
'enabled' => true,
'login' => ['enabled' => true],
'registration' => [
'google' => ['client_id' => env('GOOGLE_CLIENT_ID'), 'client_secret' => env('GOOGLE_CLIENT_SECRET')],
'corp' => ['provider' => 'keycloak', 'client_id' => 'portal', 'client_secret' => env('KC_SECRET'), 'client_name' => 'Corporate SSO'],
'billing' => ['provider' => 'corp', 'client_id' => 'billing-job', 'client_secret' => env('BILLING_SECRET'), 'authorization_grant_type' => 'client_credentials', 'scope' => ['invoices:read']],
],
'provider' => [
'corp' => ['issuer_uri' => 'https://sso.example.com/realms/corp'],
],
],
],
],With that, GET /login lists "Sign in with Google" and "Sign in with Corporate SSO", GET /oauth2/authorization/google
starts a login, GET /login/oauth2/code/google finishes it, a controller can take #[AuthenticationPrincipal] OidcUser $user,
and Http::oauth2Client('billing')->get($url) calls an API as the application.
firefly.security.oauth2.client.registration.{id} and provider.{id} are Spring Boot's spring.security.oauth2.client.*
shape verbatim, read by OAuth2ClientProperties and turned into immutable ClientRegistration value objects by
OAuth2ClientPropertiesMapper:
registration.{id}. |
Default | Meaning |
|---|---|---|
provider |
the registration id | The provider.{id} block and/or the preset this registration uses. |
client_id |
(required) | |
client_secret |
'' |
Required for client_secret_basic/client_secret_post. |
client_authentication_method |
client_secret_basic when a secret is set, none otherwise |
client_secret_basic (HTTP Basic, RFC 6749 §2.3.1), client_secret_post (form body), none (a public client — PKCE is then mandatory). |
authorization_grant_type |
authorization_code |
authorization_code (a login) or client_credentials (the application's own token). |
redirect_uri |
{baseUrl}/login/oauth2/code/{registrationId} |
A template; {baseUrl} is the application's root as UrlGenerator::to('/') builds it (honouring URL::forceScheme()/forceRootUrl() behind a proxy). |
scope |
the preset's, else [] |
A list, or a space/comma separated string. openid makes it an OpenID Connect login. |
client_name |
the preset's, else the id | The "Sign in with …" label. |
pkce |
true |
PKCE (S256) for a confidential client; a public client always uses it. |
provider.{id}. |
Default | Meaning |
|---|---|---|
issuer_uri |
— | OIDC discovery: every endpoint below that is not set is read from {issuer}/.well-known/openid-configuration. |
authorization_uri, token_uri, jwk_set_uri, user_info_uri, end_session_uri |
discovered | Explicit endpoints; an explicit key always wins over the discovered value. |
user_name_attribute |
sub |
The claim / userinfo attribute that names the principal (id for GitHub). |
Presets (CommonOAuth2Provider): a provider value — or a registration id — of google, github, okta,
keycloak or microsoft (alias entra) starts from the preset's endpoints, scopes, client_name and
client_authentication_method, and your provider.{id} keys overlay it. Google and GitHub are complete; Okta, Keycloak
and Microsoft are per-tenant and require issuer_uri, from which discovery learns the rest.
Validation happens at boot, statically and without a request: a missing client_id, a secret-less registration
with a method that needs one, an unknown grant type or method, a provider that is neither a preset nor configured,
a per-tenant preset without its issuer, a provider without issuer_uri that does not spell out authorization_uri
and token_uri (plus jwk_set_uri for an openid registration, user_info_uri for a plain OAuth2 one), an unknown
setting (client-id is refused, not ignored), and a login with no registration at all are each a
ConfigurationException naming the key. The ClientRegistrationRepository port (findByRegistrationId(),
registrationIds(), all()) is what every filter and the manager read; bind your own to source registrations from
a table of tenants.
OidcDiscovery fetches the well-known document through Laravel's Http client with the same bounded timeouts the
resource server's JWKS fetch uses (http.connect_timeout, http.timeout, 5 s each), validates it — the issuer
must equal the configured issuer_uri (OIDC Discovery §4.3), authorization_endpoint and token_endpoint must be
present — before caching the raw document for discovery.cache_ttl, and answers every failure (a 5xx, a refused
connection, a timeout, a body that is not JSON, a foreign issuer) as ProviderDiscoveryException: a 503
OIDC_DISCOVERY_UNAVAILABLE naming the host, never the URI. Resolution is lazy — a registration is resolved
on first use and memoised for the process, so firefly:cache and console boots never need the provider —
unless discovery.eager is on, in which case every issuer is discovered at boot and a dead one fails the boot
(Spring Boot's behaviour). The login page omits a provider whose discovery is down at that moment, with a warning,
rather than taking every other way in down with it.
GET /oauth2/authorization/{id} OAuth2AuthorizationRequestRedirectFilter (-89)
→ OAuth2AuthorizationRequestResolver: state (32 random bytes), nonce (always, for `openid`), PKCE verifier (S256)
→ SessionAuthorizationRequestRepository (one request per session)
→ 302 {authorization_uri}?response_type=code&client_id&scope&state&redirect_uri&nonce&code_challenge&code_challenge_method=S256
GET /login/oauth2/code/{id}?code&state OAuth2LoginAuthenticationFilter (-88)
→ the authorization request is PULLED from the session first (single use, whatever follows)
→ `error` in the query → the provider's RFC 6749 code
→ none saved / another registration's → authorization_request_not_found; state ≠ (hash_equals) → invalid_state_parameter
→ the callback URL ≠ the request's redirect_uri → invalid_redirect_uri; no code → invalid_request
→ OAuth2LoginAuthenticationProvider
DefaultOAuth2AccessTokenResponseClient: POST token_uri (form, Basic/post/none client auth, code_verifier, the two timeouts)
openid: OidcIdTokenDecoder (RS256 against the JWKS through RemoteJwksProvider, exp) → OidcIdTokenValidator
(iss, aud, azp, iat, sub, nonce — OIDC Core §3.1.3.7) → DefaultOidcUserService (userinfo when the
endpoint exists AND a profile/email/address/phone scope was granted; its `sub` must match) → DefaultOidcUser
else: DefaultOAuth2UserService: GET user_info_uri with the bearer → DefaultOAuth2User
GrantedAuthoritiesMapper bean (optional) → OAuth2AuthenticationToken::of(user, mapped, registrationId)
→ session id regenerated, SecurityContextRepository::save(), the authorized client saved (session + cache, encrypted),
AuthenticationSuccessEvent + InteractiveAuthenticationSuccessEvent('oauth2-login'),
302 saved request | login.default_success_url
→ any OAuth2AuthenticationException → AuthenticationFailureBadCredentialsEvent (username '', ip) + 302 login.failure_url
Both filters answer their own paths before HttpSecurityFilter, so no URL rule is needed for them; both are gated
by the security master flag, the package master and login.enabled, re-read live. A 503 from discovery or the JWKS
is not a refused login and propagates as the 503 it is — the token was never examined.
The login page. login.enabled implies the framework's login page, the session middleware and logout exactly as
form_login.enabled does (FormLoginSettings::$pageEnabled). The page lists every authorization_code registration
as a "Sign in with {client_name}" button through firefly/security's LoginPageLinks port — after the password form
when form login is on too, alone (no form) when it is not — and its ?error notice reads "Signing in with the
provider did not work" when there is no form. The entry point sends an anonymous browser there, with the request
saved, as for form login.
OAuth2User—getName(),getAttributes(),getAttribute(),getAuthorities()(Spring's).OidcUser extends OAuth2User, ClaimAccessor— plusgetIdToken(),getUserInfo(),getSubject(),getEmail(),getFullName(),getPreferredUsername(),getClaims(),getClaim(),hasClaim(). Its claims are the id token's with the userinfo's written over them (Spring's precedence); the name isuser_name_attributeread from that merge.DefaultOidcUseris aCredentialsContainer: the copy the session stores has the id token's raw value blanked (claims kept), the way the shippedUseris stored without its hash. The raw id token, the access token and the refresh token live only in the authorized-client entry, which is encrypted with the application key.- Authorities:
OIDC_USER(anOidcUserAuthoritycarrying the claims) orOAUTH2_USER(OAuth2UserAuthority, the attributes), thenSCOPE_xper granted scope — the same spelling the resource-server filter uses, sohasScope('x')reads both. TheGrantedAuthoritiesMapperport is where an application addsROLE_*from agroups/rolesclaim: bind an implementation as a#[Bean]; it receives the granted list (the authority carrying the claims first) and returns the list theAuthenticationcarries, while the principal keeps the granted one. - The
Authenticationcarries the registration id on its attributes (OAuth2AuthenticationToken::REGISTRATION_ID, read back withOAuth2AuthenticationToken::registrationId($authentication)), which the manager and RP-initiated logout use to know which provider signed the person in.
#[AuthenticationPrincipal] OidcUser $user (a 401 when the principal is not one — a form login's User, an acting
string), #[AuthenticationPrincipal] ?OAuth2User $user (null for those) and ?Authentication $auth all work through
firefly/security's SecurityArgumentResolver unchanged: an attributed principal is handed over when it is what the
parameter declares. hasScope('orders:read') / hasAnyScope(...) are in SecurityExpressionRoot (a bare scope is
normalised to SCOPE_), in #[PreAuthorize] expressions, and in the URL vocabulary as hasScope:orders:read;
SecurityExpressionEvaluator::authorities() reports them as SCOPE_x in requiredAuthorities.
logout.oidc_initiated binds OidcClientInitiatedLogoutSuccessHandler on firefly/security's LogoutSuccessHandler
port. The LogoutFilter asks it before invalidating the session — so it can still read the encrypted authorized
client — and it answers a 302 to the provider's end_session_endpoint (explicit end_session_uri or discovered)
with id_token_hint, client_id and post_logout_redirect_uri (logout.post_logout_redirect_uri, {baseUrl}
expanded; default {baseUrl}/login?logout, the framework page's signed-out notice). A principal that did not sign in
through OAuth2, a registration that no longer exists, or a provider without an end-session endpoint (logged at
warning) gets the default logout_success_url redirect — the person is signed out of this application either way.
OAuth2AuthorizedClientManager::authorize($registrationId, $principalName = null) (Spring's) hands out an
OAuth2AuthorizedClient — the registration id, the principal name, the access token, the refresh token, the raw id
token; never the registration, so a secret never enters a session or a cache:
- a
client_credentialsregistration is fetched with the client's own credentials and configured scopes, cached per registration (and per$principalNamewhen one is given) in theOAuth2AuthorizedClientService(the Laravel cache, encrypted), and refetched when the access token is withinclock_skewseconds of expiring; - an
authorization_coderegistration is read from the session (OAuth2AuthorizedClientRepository, the encrypted entry the login wrote) when the current request has one, else from the cache service under$principalName(else the signed-in name) — the path a queued job takes — refreshed with the refresh token when about to expire (the rotated refresh token and the id token kept, the result written back to both stores), and refused withClientAuthorizationRequiredException(401CLIENT_AUTHORIZATION_REQUIRED: sign in through that registration) when there is nothing, or nothing to refresh with. A refresh the provider answersinvalid_grantto removes the dead entry and propagates as the 503OAUTH2_INVALID_GRANTit is; the next call is the 401.
Http::oauth2Client('billing') (PendingRequest) is Spring's ServletOAuth2AuthorizedClientExchangeFilterFunction:
the application's Http factory with that bearer attached — faked by Http::fake(), traced by the observability
wave's client middleware, ->retry()-able like any other request. Http::oauth2Client('corp', 'ada') names the
principal. Registered at provider register() time behind http.macro (so Larastan types it); calling it with the
package off is a ConfigurationException naming firefly.security.oauth2.client.enabled.
stateis single-use (pulled from the session before anything is compared), session-bound (it lives nowhere else) and compared in constant time; the callback URL must equal the request'sredirect_uriexactly.- PKCE is on by default and cannot be switched off for a public client; a nonce is always sent for
openidand always checked (constant time) on the id token;iss,aud,azp,iat,subandexpare checked per OIDC Core §3.1.3.7 withclock_skewseconds of leeway. - Tokens are stored encrypted with the application key (session entry and cache entry); the principal in the
session carries claims and never a token;
ClientRegistration::__debugInfo()masks the secret; the token exchange's exceptions name the registration and the RFC code and never the code, the verifier, the secret or a token. - Every outbound call (discovery, token, JWKS, userinfo) has bounded timeouts and goes through the container's Http factory (traced, fakeable).
- Both filters clear
SecurityContextHolderin afinally, so nothing bleeds into the next request under Octane.
| Order | Filter |
|---|---|
| -95 | SecurityHeadersFilter |
| -94 | SecurityContextPersistenceFilter |
| -93 | LogoutFilter |
| -92 | FormLoginFilter |
| -91 | HttpBasicFilter |
| -90 | JwtAuthenticationFilter |
| -89 | OAuth2AuthorizationRequestRedirectFilter |
| -88 | OAuth2LoginAuthenticationFilter |
| -85 | OAuth2ResourceServerFilter |
| -83 | RememberMeAuthenticationFilter |
| -80 | CsrfFilter |
| -70 | HttpSecurityFilter |
enabled is the package master: registrations, discovery, the token client, the authorized-client stores and
manager, and the Http::oauth2Client() macro's target. It does not need firefly.security.enabled — a job that
calls an API with client credentials has no inbound security to speak of. login.enabled does need it (the login
filters sign into master-gated beans) and is refused at boot without it, or without enabled, or with no
registration at all; logout.oidc_initiated is refused without login.enabled.
| Key | Default | Meaning |
|---|---|---|
enabled |
false |
The package master (see above). |
registration.{id}.* |
— | provider, client_id, client_secret, client_authentication_method, authorization_grant_type, redirect_uri, scope, client_name, pkce — see Registrations and providers. |
provider.{id}.* |
— | issuer_uri, authorization_uri, token_uri, jwk_set_uri, user_info_uri, user_name_attribute, end_session_uri. |
login.enabled |
false |
oauth2Login(): the two filters, the login-page links, the session repository of authorized clients. Implies the login page, the session middleware and logout (FormLoginSettings::$pageEnabled, SessionSecuritySettings, LogoutSettings). Requires enabled and firefly.security.enabled. |
login.authorization_endpoint_base_uri |
/oauth2/authorization |
GET {base}/{id} starts a login. |
login.redirection_endpoint_base_uri |
/login/oauth2/code |
GET {base}/{id} is the callback; also the default redirect_uri template's path. |
login.default_success_url |
/ |
Where a login lands when no request was saved. |
login.always_use_default_success_url |
false |
Ignore the saved request. |
login.failure_url |
/login?error |
Where a refused login is redirected. |
logout.oidc_initiated |
false |
RP-initiated logout on the LogoutSuccessHandler port. Requires login.enabled. |
logout.post_logout_redirect_uri |
{baseUrl}/login?logout |
Sent to the provider as post_logout_redirect_uri ({baseUrl} and {registrationId} expand). Register it at the provider. |
clock_skew |
60 |
Seconds of leeway on exp/iat of an id token, and the margin before an access token's expiry at which the manager refreshes it. |
http.connect_timeout / http.timeout |
5 / 5 |
Seconds, for discovery, the token endpoint, the JWKS and userinfo. Laravel's default (30) equals PHP's execution limit. |
http.macro |
true |
Whether Http::oauth2Client() is registered. |
discovery.cache_ttl |
3600 |
Seconds the raw discovery document is cached (validated before caching; a failure caches nothing). |
discovery.eager |
false |
Resolve every registration at boot: a dead issuer fails the boot (ProviderDiscoveryException), Spring Boot's behaviour. |
jwk_set.cache_ttl |
3600 |
Seconds a provider's JWKS is cached (RemoteJwksProvider). |
authorized_client.cache_ttl |
86400 |
Seconds a client that can be renewed (a refresh token, or an access token without expiry) stays in the cache service; a bare one stays until its access token expires. |
Firefly\Testing\Security\OAuth2\FakeAuthorizationServer is a whole OpenID Connect provider in one class:
abstract class LoginTestCase extends SecurityCapstoneTestCase // or any FireflyTestCase
{
public FakeAuthorizationServer $idp;
protected function configOverrides(): array
{
return [
...parent::configOverrides(),
'firefly.security.oauth2.client.enabled' => true,
'firefly.security.oauth2.client.login.enabled' => true,
'firefly.security.oauth2.client.registration.fake' => FakeAuthorizationServer::registrationConfig(),
'firefly.security.oauth2.client.provider.fake' => FakeAuthorizationServer::providerConfig('http://localhost/fake-idp'),
];
}
protected function defineFireflyEnvironment(Application $app): void
{
$this->idp = FakeAuthorizationServer::install($app, 'http://localhost/fake-idp');
}
}install() mounts the front channel as real routes on the application at the issuer's path (/authorize,
auto-approving or — requireConsent() — rendering a consent page whose form approves; /end-session) and fakes the
back channel with Http::fake() for exactly {issuer}/.well-known/openid-configuration, /token, /jwks and
/userinfo (anything else is left to the real client, so a stray request is still a stray request). Tokens are RS256
JWTs signed with a key pair generated once per process; codes are single-use, bound to their redirect_uri and
PKCE-checked; the token endpoint authenticates the client by Basic header, form body, or — acceptPublicClient() — as a
public client; refresh tokens rotate; userinfo needs a token it issued; the clock is Date::now(), so $this->travel()
expires tokens. Every hop is recorded (authorizationRequests, tokenRequests, userInfoRequests, endSessionRequests,
discoveryRequests, jwksRequests, issuedIdTokens, issuedAccessTokens; lastAuthorizationRequest(),
lastTokenRequest()), and the knobs make each failure reproducible: withUser(), expiresIn(),
overrideIdTokenClaims() (a null removes a claim), signWithUnknownKey(), refuseToken(), refuseAuthorization(),
withoutUserInfo(), takeDiscoveryDown(). Laravel's test client follows the redirects in-process; the browser suite
(tests/Browser/OAuth2LoginTest.php) drives the same fake from Chromium, because the plugin serves the app in-process.
FireflyTestCase::actingAsOidcUser() signs a real OidcUser in for the rest of a test — claims, scopes, extra
authorities, the registration id — with no provider involved:
$this->actingAsOidcUser(['sub' => 'ada', 'email' => 'ada@example.com'], ['ROLE_ADMIN'], 'okta', ['openid', 'profile']);and actingAsAuthentication(Authentication $token) is the seam beneath it and actingAsPrincipal(). The package's own
suites are the reference: packages/security-oauth2-client/tests/Support/OAuth2ClientCapstoneTestCase.php boots the
real providers with the fake installed, and every flow (the login page, the redirect, the callback, each refusal, the
principal in a controller, scope rules, the mapper, RP-initiated logout, the manager and the macro) runs through the
real HTTP pipeline.
| Concern | Plain Laravel | LaraFly (firefly/security-oauth2-client) |
|---|---|---|
| Social / SSO login | Socialite: a driver per provider, a controller you write, Auth::login() |
configuration in Spring Boot's shape, presets + OIDC discovery, two filters, the framework login page |
| Id token | not validated (Socialite reads userinfo) | signature against the JWKS, iss/aud/azp/exp/iat/nonce/sub per OIDC Core |
| PKCE / state | driver-dependent | PKCE S256 by default (mandatory for public clients), single-use constant-time state, exact redirect_uri |
| Principal | an Eloquent user you create from the provider's payload | OidcUser/OAuth2User with claims, SCOPE_*, a GrantedAuthoritiesMapper seam, injected into actions |
| Logout at the provider | manual | logout.oidc_initiated on the LogoutSuccessHandler port |
| Calling APIs | manual token storage and refresh | OAuth2AuthorizedClientManager, Http::oauth2Client(), encrypted stores |
| Testing | Socialite::fake() or mocks |
FakeAuthorizationServer (a real provider, in-process) and actingAsOidcUser() |
Remember-me is not set on an OAuth2 login (Spring's AbstractAuthenticationProcessingFilter would call
rememberMeServices.loginSuccess()); the session's own lifetime is the login's. ClientAuthorizationRequiredException
from a controller is answered as the 401 it is — Spring's redirect filter would start the authorization flow for that
registration instead, which Laravel's route pipeline (which turns a controller's exception into a response before any
global middleware sees it) does not allow at the filter level. A registration whose tokens should be shared between
processes without a request (a queue worker acting as a person) relies on the cache service, whose entries expire with
authorized_client.cache_ttl; a durable store is a OAuth2AuthorizedClientService binding of your own. The
authorization server, real IdP adapters beyond the presets, and MFA are the next waves.