This chapter needs an extra package
composer require naf/oauth-client
Also installs these transitive dependencies: naf/auth, naf/session.
Signing in with a provider¶
Sign people in with Google, Microsoft or any OpenID Connect provider — and keep your own user model.
<?= oauth_button('google') ?>
That is the whole integration. The routes, the protocol and the account lookup are already wired; what is left for you is the one decision nobody else can make — see Which account is this?
Install it when people should sign in with an account they already have.
What this plugin is¶
It answers one question — who does the provider say this is? — and hands back an
ExternalIdentity: an issuer, a subject, and the claims that came with them.
Everything the protocol asks for in between is not configurable, because none of it is a decision an application should be making:
| PKCE | S256, always, for confidential clients too |
state |
random, stored server-side, consumed once |
nonce |
issued and checked against the ID token |
| Signature | always verified against the provider's published keys, wherever there is an ID token |
| Algorithm | pinned to what the provider publishes, never read from the token's own header |
| Claims | iss, aud, exp, iat, nonce, sub are checked; azp is checked when present |
| Metadata | the document has to name the issuer you configured before anything in it is used |
| Redirect URI | derived from your public URL, sent exactly, never taken from a request |
| Transport | https only, and no redirects followed while credentials are in flight |
| Key rotation | an unknown key id reloads the key set, once, with a cooldown |
A provider whose keys cannot be read makes the login fail. It never makes the check optional.
The configured issuer is the trust anchor — the one thing you actually stated about a provider. Everything else, endpoints and signing keys included, arrives over the network, so a discovery document has to prove it belongs to that issuer before anything in it is used and before a client secret is sent to an address it names.
Signing somebody in is the whole of it by default, and stores nothing the provider issued. An application that also has to act at the provider afterwards — read a calendar, list repositories — can keep those tokens instead; that is opt-in and lives in Calling the provider afterwards.
Configuration¶
Google¶
The whole thing:
// app/config.php
return [
'public_url' => 'https://example.com',
'auth' => [
'users' => ['model' => App\Models\User::class],
'logins' => [
'google' => [
'client_id' => $_ENV['GOOGLE_CLIENT_ID'],
'client_secret' => $_ENV['GOOGLE_CLIENT_SECRET'],
],
],
],
];
Two things: where your accounts live, and which logins you offer. The connection, the callback routes, the session, the link table, the HTTP transport and the landing page are all taken from what is already there.
The diagnostic commands need composer require naf/cli.
For the API-call example later, also install naf/client.
Then ask what to register with the provider:
vendor/bin/naf oauth:discover google
It reads the provider's metadata, proves the setup resolves, and prints the callback URL to paste into their console.
public_url is required and has no default. Inside a request the only candidates are the
Host and X-Forwarded-* headers, and those are set by whoever is calling. Every redirect URI
is built from this one value, so it is stated once rather than guessed.
One provider, several logins¶
The key names the login; driver says which provider is behind it. Keeping them apart is what
lets you offer the same provider twice under names of your choosing:
'logins' => [
'staff' => ['driver' => 'microsoft', 'tenant' => 'acme.example', 'client_id' => …, 'client_secret' => …],
'partners' => ['driver' => 'microsoft', 'tenant' => 'partner.example', 'client_id' => …, 'client_secret' => …],
],
Each gets its own callback (/auth/staff/callback, /auth/partners/callback), its own button
and its own credentials. Without a driver, the key is the driver — which is why google above
needs nothing else.
Microsoft¶
Microsoft needs one more thing: which accounts may sign in.
'microsoft' => [
'client_id' => $_ENV['MS_CLIENT_ID'],
'client_secret' => $_ENV['MS_CLIENT_SECRET'],
'tenant' => '8f3a1c94-…', // directory id, or a verified domain
],
A single named tenant pins the issuer by itself. The multi-tenant values — common,
organizations, consumers — mean any organisation in the world may sign in, so they will
not start without you saying who:
'tenant' => 'common',
'allowed_tenants' => ['8f3a1c94-…', 'b21d…'], // or ['*'] to accept everyone
Without allowed_tenants the setup raises a ConfigurationException naming the key. This is
deliberate: with common, Microsoft's discovery document gives its issuer as a template, and
the real issuer is only known from the token's own tenant id. Substituting it is safe because
the tenant is then checked against your list.
Any other OpenID Connect provider¶
One extra line — the issuer. Endpoints and keys come from its discovery document.
'keycloak' => [
'issuer' => 'https://id.example.com/realms/main',
'client_id' => …,
'client_secret' => …,
],
GitHub, and other providers without OpenID Connect¶
'github' => [
'client_id' => $_ENV['GITHUB_CLIENT_ID'],
'client_secret' => $_ENV['GITHUB_CLIENT_SECRET'],
],
GitHub has no discovery document and issues no ID token, so its endpoints are named in the preset rather than read from it. Its quirks are handled for you: the credentials go where GitHub documents them, its API version header is sent, and an address the person has not made public is fetched from their verified address list instead of coming back empty.
Naming a profile endpoint does not make a provider plain OAuth2. Protocol and profile are
separate: give an OpenID Connect provider a userinfo_url and it stays OpenID Connect — the ID
token is verified first, the profile is fetched afterwards, and it is only accepted if it
describes the same subject. It adds to a verified identity; it never establishes one.
A provider is plain OAuth2 when it has no issuer. Such a provider takes three URLs and, if it
does not call its identifier sub, the field that holds it:
'acme' => [
'client_id' => …,
'client_secret' => …,
'authorize_url' => 'https://acme.test/oauth/authorize',
'token_url' => 'https://acme.test/oauth/token',
'userinfo_url' => 'https://acme.test/api/me',
'subject_field' => 'user_id',
],
GitLab needs none of this — it publishes an OpenID Connect discovery document, so
'issuer' => 'https://gitlab.com' is the whole configuration.
Two trust paths, and the difference between them¶
With OpenID Connect the provider signs a statement addressed to this application, and both the signature and the address are verified. Without it there is nothing to sign: the identity comes from an authenticated call to the provider's API with the access token just exchanged for the code.
For the authorization-code flow that is sound — the token was minted for this client id, against
this redirect URI, with this PKCE verifier, so no token from anywhere else can reach that call.
It is weaker in kind rather than in strength: there is no audience-bound assertion to re-check
later, and nothing binds the answer to this particular login beyond the token itself. That is
also why no nonce is sent to such a provider — there would be nothing to bind it to.
One consequence worth knowing: a plain-OAuth2 provider states no issuer, so identities are filed
under oauth:<provider key>. Renaming the key in your configuration detaches existing links.
With OpenID Connect the issuer is the provider's own and renaming is harmless.
When a provider rotates your client secret¶
Change the one line and deploy. Nothing else moves: the client_id stays, the callback URL stays,
every account link stays, and nobody is signed out. A client secret is only ever used at the token
endpoint, between your server and theirs — it never reaches a browser and never identifies anyone.
There is nothing to clear either. The secret is read from the configuration on the request that needs it; what gets cached is discovery documents and signing keys, and neither contains it.
The awkward part is that two sides have to change and they cannot do it in the same instant. A
provider worth using holds both for a while — a naf/oauth-server does:
vendor/bin/naf oauth:client:rotate-secret <client-id>
Deploy the new value here before that window closes; when it lapses, the old secret simply stops
being accepted and nothing has to run for that to happen. After a leak the server's --now ends
it immediately, and this application stops authenticating until the new secret is deployed — which
is the point, not a side effect.
Check it before anybody tries¶
vendor/bin/naf oauth:doctor
It verifies the dependencies, the public URL, the user model and its contract, the link table, and every configured login — including whether the provider's metadata is reachable and belongs to the issuer you configured. It prints the callback URL to register, and never prints a secret.
The older configuration¶
oauth:providers is the previous spelling of auth:logins and still works; it is used when
auth:logins is absent, and the error messages then name the keys you actually wrote.
auth:providers likewise still names account sources explicitly. Nothing has to be migrated.
Everything else has a default¶
callback_url, scope, label, after_login, error_route, the table name and the cache
location are derived or defaulted. Set them when you actually need something else.
The button¶
use function Naf\OAuth\Client\oauth_button;
<?= oauth_button('google') ?> <!-- "Mit Google anmelden" -->
<?= oauth_button('google', 'Continue with Google') ?> <!-- explicit text wins -->
<?= oauth_button('google', next: '/projects/7') ?> <!-- land there afterwards -->
The wording is settled highest-first: what you pass in, then your own
oauth/button.phtml in the application's view directory, then
oauth:providers:<key>:label, then the provider's own name. Copy the shipped view to change
the markup — yours wins, and nothing in the central configuration has to change for it.
A label is only ever a label. It never reaches an issuer, a client id, a redirect URI or a subject.
The routes¶
Shipped, named, and off with 'oauth' => ['routes' => false]:
GET /auth/{provider} |
start a login |
GET /auth/{provider}/callback |
what the provider sends back |
GET /auth/{provider}/connect |
attach this provider to the account already signed in |
All three take ?next=/somewhere — always a local path. Anything else falls back to
oauth:after_login, and "anything else" includes embedded control characters: browsers strip
tabs and newlines while normalising a URL, so /\r//evil.test would otherwise arrive as
//evil.test.
A provider reporting a failure is treated like any other answer: its state is checked and
consumed. A cancelled login therefore ends rather than sitting pending until it expires, and
nobody can drive the callback by appending ?error= to it.
A refused login redirects to oauth:error_route with the reason flashed into the session as
oauth_error, rather than raising an error page. A cancelled login is something a person did,
not a server fault:
<?php if ($reason = session()->getFlash('oauth_error')): ?>
<p><?= $reason === 'not_linked'
? 'No account is linked to that login yet.'
: 'That login could not be completed.' ?></p>
<?php endif ?>
Which account is this?¶
The one decision left to you. Three answers are possible and only one is safe by default:
- somebody linked it before → that account is signed in. Nothing to write.
- nobody did, and you allow it → you create the account, the link is written, they are signed in.
- nobody did → refused with
not_linked, so they can sign in normally and connect it.
Turning on the middle one is one setting and one function:
'oauth' => ['accounts' => [
'auto_register' => true,
'create' => static fn(ExternalIdentity $external) => $users->create([
'email' => $external->email,
'name' => $external->name,
]),
]],
With several account sources registered in auth:providers, name the one that owns external
logins in oauth:accounts:provider. With one, it is used without being named.
Two rules it enforces for you¶
Identities are keyed on (issuer, subject), never on subject alone. A subject is only
unique within its issuer; two providers can hand you the same string. The link table's primary
key is that pair, so two simultaneous first logins cannot both create a link.
Creating an account and linking it are one act. They run in one transaction, so a failed link takes the half-made account with it — otherwise every retry leaves another orphan beside the last one. The new account is then read back through the configured source before it signs in: what signs in has to be what the next request will load, and the source is what decides who may sign in at all.
A matching e-mail address is never a link. Not even a verified one: anybody who can get a provider to assert an address could then walk into the account that uses it. Attaching a second provider is a separate act, performed by somebody already signed in — and the person who finishes it must be the person who started it:
<a href="<?= route('oauth.connect', ['provider' => 'github']) ?>">Connect GitHub</a>
Doing it yourself¶
Nothing above is mandatory. oauth() gives you the same flow with none of the routing:
use function Naf\Auth\auth;
use function Naf\OAuth\Client\oauth;
return redirect(oauth('google')->authorizationUrl());
$callback = oauth('google')->callback(); // throws unless everything verifies
$external = $callback->identity;
$user = $yourAccounts->findBySubject($external->issuer, $external->subject)
?? throw new RuntimeException('Not linked.');
auth()->setIdentity($user, 'database');
return redirect($callback->redirectTo);
Writing the callback yourself means storing provider tokens yourself too — the shipped route calls
Tokens::remember($callback) after the sign-in has succeeded, and nothing else does. Keep that
order: a callback nobody is allowed to finish must not leave a working credential behind.
Several tabs¶
Logins are keyed by their own state, so a person with three tabs open finishes all three.
Each entry is consumed on use and expires after ten minutes, which is also what makes a
replayed callback fail.
What comes back¶
Callback: identity, purpose (LOGIN or LINK), initiator, redirectTo, token.
ExternalIdentity: provider, issuer, subject, claims, and email / emailVerified /
name for convenience. emailVerified is true only for a literal true — providers have sent
"true", 1 and "1" there.
Calling the provider afterwards¶
A login needs none of this. The identity is checked once and your own session is the authority from then on, which is why signing in stores no provider tokens at all.
It is a different question when the application has to act at the provider — read somebody's calendar, list their repositories, post on their behalf. That needs what the provider issued, kept between visits, and still working an hour later.
// app/config.php
'oauth' => [
'tokens' => [
'store' => true,
'key' => 'BASE64_OF_32_RANDOM_BYTES',
],
],
naf oauth:doctor says how to generate the key; it deliberately does not print one. Everything in
oauth_provider_tokens is encrypted with it, so a copy of the database is not a copy of anybody's
permissions — which is also why the key does not belong in that database. Losing it costs everybody
a new consent screen.
Then, wherever the API call happens:
use function Naf\OAuth\Client\oauth;
use function Naf\OAuth\Client\oauth_token;
use function Naf\Auth\auth;
use function Naf\Client\client;
use function Naf\redirect;
use Nyholm\Psr7\Request;
$token = oauth_token('google');
if ($token === null || !$token->grants('https://www.googleapis.com/auth/calendar.readonly')) {
return redirect(oauth('google')->grantUrl(
['https://www.googleapis.com/auth/calendar.readonly'],
auth()->providerName(),
(string) auth()->id(),
));
}
$response = client()->sendRequest(new Request(
'GET',
'https://www.googleapis.com/calendar/v3/calendars/primary/events',
['Authorization' => 'Bearer ' . $token->accessToken],
));
oauth_token() hands out something usable or nothing: an expired access token is renewed on the
way out and the renewal written back. A provider that cannot be reached throws rather than
answering null, because an outage is not a withdrawn permission and must not send people through a
consent screen that cannot help.
Without consent there is no access, and consent is not what you asked for¶
What the application may do is decided entirely by the scopes on the consent screen. Ask for
nothing beyond openid email profile — the default — and there is no API access at all.
Asking is not getting. RFC 6749 §5.1 obliges a provider to state the scope it actually issued
whenever it differs from the request, and Google lets people untick individual permissions. So
$token->scope is the granted scope, never the requested one, and $token->grants(...) is worth
asking before a call rather than after a 403 that will not explain itself.
Ask when the feature is used, not at the login¶
grantUrl() exists so that extra permissions are requested at the moment they are needed. "Wants
to see your calendar" makes sense after somebody pressed a calendar button; at a login it reads as
a reason not to sign in. It returns through the ordinary callback, so there is no second route to
add.
A grant replaces the stored token with what the provider issued for it. Google is asked with
include_granted_scopes, so its answer carries the earlier permissions too; a provider that does
not do this issues a token for the new scope alone, and the previous one is gone.
Two things that catch people out¶
The refresh token arrives once. Google returns it on the first consent and never again unless
consent is forced — grantUrl() forces it, the login does not. An installation that turns
oauth:tokens:store on after people have signed in has no refresh token for any of them until
they grant something again.
Unlinking is not revoking. Deleting the link only makes the application forget; the permission
stays listed in the person's account at the provider, looking current. Tokens::forget() tells the
provider first and then deletes, and belongs wherever an account is unlinked.
When it does not verify¶
Every failure is an OAuthException carrying a short, stable reason alongside its message, so
an error page can tell a cancelled login from an expired one without matching on prose:
provider_error, state_missing, state_unknown, code_missing, provider_mismatch,
token_request_failed, id_token_missing, id_token_invalid, nonce_mismatch,
issuer_mismatch, audience_mismatch, tenant_not_allowed, subject_missing, no_keys,
metadata_unavailable, no_session, access_token_missing, userinfo_unavailable,
subject_mismatch, not_linked, already_linked, initiator_mismatch, no_initiator,
account_unavailable, registration_refused, consent_required, refresh_failed, no_scopes.
already_linked means exactly that — an integrity violation and nothing else. A dropped
connection or a value too long for its column surfaces as the database error it is, rather than
hiding a broken installation behind a message that sounds like ordinary use.
A misconfiguration raises ConfigurationException instead, and is left to surface the way every
other bug in your application does — it is addressed to you, not to a visitor.
Provider metadata¶
Discovery documents and signing keys are cached under storage/oauth for a day, so a login
costs no extra request. Point oauth:cache_path somewhere else when that directory is not
writable, or when several servers should share one cache. A token signed with a key id we have not seen reloads the key set once —
which is what a rotation looks like from here — and a cooldown keeps invented key ids from
turning into a stream of outbound requests. A failed reload keeps using what is cached and backs off before trying again, so a provider that
is down does not turn every login into another outbound request. It never degrades into
accepting an unverified token — and it does not lean on stale metadata forever either: once the
cache has been expired for a day without the provider answering, logins fail with something an
operator can act on.
What this is not¶
There is no LDAP adapter here. Names like ldap appear in tests and in naf/auth's
examples as stand-ins for "a second account source"; that is not support, and nothing in these
packages speaks LDAP.
What does exist is the part that matters for adding one: every way of signing in ends at the
same local contract, UserInterface. A directory bind would not be an OAuth provider and must
not be forced through a redirect flow — it verifies a password against a server and then hands
over a user, which is what auth()->setIdentity() is for. The protocol differs; what a
signed-in person is does not.
Where credentials belong¶
| Local passwords | hashed, never recoverable — PasswordHasher |
| Directory passwords | verified against the directory, never stored locally |
| Provider secrets, bind accounts | server-side configuration, out of the repository |
| Account links | stable external ids — (issuer, subject), never an e-mail address |
| Your own tokens | the token store, as hashes |
| Providers' refresh tokens | only when you actually call their APIs — oauth_provider_tokens, encrypted with oauth:tokens:key |
| Your own client secrets | server-side configuration; rotated by changing one line, see above |
A known limit¶
Taking a pending login out of the session is read-modify-write, and what makes that indivisible
is the session backend holding a lock for the request. PHP's own file handler does;
naf/session's database handler does not. Two callbacks arriving for the same state in the
same instant could therefore both find it there.
Every check after that still applies — the authorization code is single-use at the provider, and the ID token still has to verify — so this narrows the replay guarantee rather than opening a way through it. A locking session backend closes it.
Not here yet¶
- Rotating
oauth:tokens:key. Changing it makes every stored grant unreadable, and everybody affected has to grant access again. Re-encrypting in place would need both keys held at once.