Single sign-on for CodeIgniter Shield
Let people sign in to your CodeIgniter app with their organization's account: Microsoft Entra ID, Okta, Google Workspace, Keycloak, Auth0 or any OpenID Connect provider. They come back as ordinary Shield users, with groups mapped from their roles if you want.
composer require daemonlabs/shield-oidc
php spark oidc:setupEvery ID token's signature, issuer, audience, expiry and nonce are checked before anything in it is trusted.
People are matched on a stable provider ID. An email address only counts when it can be trusted.
One tenant or many, personal accounts, tenant allow-lists, and roles that other organizations can't grant themselves.
Misspelled settings are errors, odd provider data is a failed sign-in, and oidc:check warns about risky setups.
Requirements#
- PHP 8.1 or newer with the
curl,jsonandopensslextensions. - CodeIgniter 4.5 or newer, with Shield 1.1 or newer installed and set up (
php spark shield:setup). - HTTPS in production. Providers only send people back to https addresses (localhost excepted).
- An app registration at your provider: a client id, and for most setups a client secret.
Installation#
-
Install the package and create its config file.
bashcomposer require daemonlabs/shield-oidc php spark oidc:setupoidc:setupwritesapp/Config/ShieldOidc.php. The package's routes, commands, helper and messages are picked up by CodeIgniter's module discovery; there's nothing to register. -
Let people reach the sign-in routes. If you use Shield's global
sessionfilter to require sign-in everywhere, exemptoidc/*(or<routePrefix>/*), or the button just sends people back to the login page.php// app/Config/Filters.php public array $globals = [ 'before' => [ 'session' => ['except' => ['login*', 'register', 'auth/a/*', 'logout', 'oidc/*']], ], ]; -
Add a provider to
app/Config/ShieldOidc.php(see the provider guides), then check it:bashphp spark oidc:checkIt loads the provider's settings, verifies them, and prints the redirect URI to register with the provider.
-
Show the button on your login page:
php<?php helper('oidc') ?> <?= oidc_buttons() ?>To put it on Shield's own login page, copy Shield's
login.phpview into your app, pointConfig\Auth::$views['login']at the copy, and add those two lines. Sign-in errors come back assession('error'), which Shield's views already display.
With Config\Modules::$discoverInComposer = false (or a restricted $composerPackages), CodeIgniter won't find the package's routes, commands or helper. Register its namespace yourself:
// app/Config/Autoload.php
public $psr4 = [
APP_NAMESPACE => APPPATH,
'DaemonLabs\ShieldOidc' => ROOTPATH . 'vendor/daemonlabs/shield-oidc/src',
];How sign-in works#
The package uses OpenID Connect's authorization code flow with PKCE. The browser carries only a one-time code and a random state; the tokens travel directly between your server and the provider.
A few details matter for security:
- The state ties the callback to the browser session that started it, and is single-use. A callback URL copied into another browser fails.
- The nonce ties the ID token to this one sign-in, so a token issued for an earlier one can't be replayed.
- The PKCE verifier means a stolen code can't be exchanged by anyone else.
- Only after the token is verified does account matching decide which Shield user this is.
Microsoft Entra ID#
Register the app
- In the Entra admin centre, open Entra ID > App registrations and choose New registration.
- Under Supported account types, pick who can sign in. It must match your
tenantsetting (see the table below): Single tenant only for one organization (recommended), Multiple Entra ID tenants fororganizations, Any Entra ID Tenant + Personal Microsoft accounts forcommon, Personal accounts only forconsumers. - Under Redirect URI, choose Web and paste the URI that
php spark oidc:checkprints, e.g.https://example.com/oidc/entra/callback. - Copy the Application (client) ID and the Directory (tenant) ID. Under Certificates & secrets > Client secrets, create a secret and copy its value. Entra allows at most 24 months and recommends under 12; note when it expires.
- Under Token configuration > Add optional claim > ID, add
emailandxms_edov(see which claims to add).
A Web app needs its client secret: Entra doesn't accept public clients (PKCE only) on a Web redirect URI. Certificate credentials (private_key_jwt), which Microsoft prefers for production, aren't supported yet.
Configure it
namespace Config;
use DaemonLabs\ShieldOidc\Config\ShieldOidc as BaseShieldOidc;
class ShieldOidc extends BaseShieldOidc
{
public array $providers = [];
public function __construct()
{
parent::__construct();
$this->providers['entra'] = [
'type' => 'entra',
'tenant' => env('ENTRA_TENANT'), // tenant id or domain
'clientId' => env('ENTRA_CLIENT_ID'),
'clientSecret' => env('ENTRA_CLIENT_SECRET'),
];
}
}ENTRA_TENANT = 11111111-2222-3333-4444-555555555555
ENTRA_CLIENT_ID = 66666666-7777-8888-9999-000000000000
ENTRA_CLIENT_SECRET = "your-secret-value"Keep profile in scopes (it's in the default): Entra sends the oid claim that identifies people only with that scope. For US Government cloud set 'authority' => 'https://login.microsoftonline.us'; for China (21Vianet) 'authority' => 'https://login.partner.microsoftonline.cn'.
One organization or several
tenant | Who can sign in |
|---|---|
| tenant id or domain | Only your organization. Recommended. |
organizations | Any work or school account, from any organization |
common | Work, school and personal Microsoft accounts |
consumers | Personal Microsoft accounts only |
With several organizations, limit who gets in with allowedTenants, a list of tenant ids. Tokens are always checked against the tenant they came from, following Microsoft's documented rule: an organizations setup refuses personal accounts, the token's issuer and tid must agree, and a tenant configured by id must be that tenant.
Every organization's administrators control their own users' details, including email addresses and which of your app's roles they hold. So the package ignores roles from other tenants unless you list them in groupTenants, and only trusts emails that carry Entra's xms_edov claim (the nOAuth problem).
Which claims to add
Under Token configuration > Add optional claim > ID (or in the app manifest's optionalClaims.idToken):
| Claim | Why |
|---|---|
email | The person's address, for new accounts and allowedDomains. |
xms_edov | Says the address's domain is verified by the issuing tenant. Without it, Entra emails aren't trusted: new users get no email, existing accounts aren't linked by email, and allowedDomains admits no one. Microsoft notes even a single tenant's email can hold an address an administrator typed in. |
auth_time | Optional. When present, connecting an account also checks the sign-in really was fresh. |
login_hint | Optional. With logoutAtProvider, signing out skips Entra's account picker. |
"optionalClaims": {
"idToken": [
{ "name": "email" },
{ "name": "xms_edov" },
{ "name": "auth_time" },
{ "name": "login_hint" }
]
}Entra people are identified by their oid (object id), Microsoft's recommended identifier: the same across your app registrations, unlike the per-app sub. The userinfo call is off by default for Entra, since Microsoft documents it as returning only what the ID token already has. Apps with custom signing keys (claims mapping) aren't supported.
Google Workspace#
In the Google Cloud console, create an OAuth client of type Web application and add the redirect URI from oidc:check. Then:
$this->providers['google'] = [
'issuer' => 'https://accounts.google.com',
'clientId' => env('GOOGLE_CLIENT_ID'),
'clientSecret' => env('GOOGLE_CLIENT_SECRET'),
'requiredClaims' => ['hd' => 'yourcompany.com'],
];hd to mean "our Workspace"
Google adds the hd claim only for accounts that belong to a Google Workspace or Cloud Identity organization, set to that organization's domain; requiredClaims checks it's yours. The email domain alone proves nothing: someone can make a personal Google account with a company address. Setting the OAuth client's audience to Internal in the console also limits sign-in to your organization. Without either, any Google account can sign in.
- Emails. Following Google's guidance, an email counts only if it's an
@gmail.comaddress, oremail_verifiedis true andhdis present. A personal account's third-party address may have been verified years ago by someone who no longer owns it. - Connecting an account. Google documents only
prompt=none|consent|select_accountand doesn't sendauth_timeby default, so linking shows Google's account picker (select_account) rather than forcing a new password entry. - Logout. Google has no provider logout endpoint, so
logoutAtProvidersigns people out of your app only.
Okta#
Create an app integration (OIDC – OpenID Connect, Web Application), add the redirect URI under Sign-in redirect URIs and your $logoutRedirect address under Sign-out redirect URIs, and assign the people or groups who may use it (unassigned people come back with "access denied").
$this->providers['okta'] = [
'label' => 'Okta',
'issuer' => 'https://your-org.okta.com', // or .../oauth2/default
'clientId' => env('OKTA_CLIENT_ID'),
'clientSecret' => env('OKTA_CLIENT_SECRET'),
'logoutAtProvider' => true,
];Use the issuer exactly as Okta shows it: https://your-org.okta.com for the org authorization server, or https://your-org.okta.com/oauth2/default (or /oauth2/<id>) for a custom one. Okta sends auth_time in every ID token, so the fresh-sign-in check for connecting accounts fully applies.
Groups
- Org authorization server (
https://your-org.okta.com): on the app's Sign On tab, set a Groups claim filter (e.g. groups, Matches regex,^app-.*), and request thegroupsscope:'scopes' => ['openid', 'profile', 'email', 'groups']. - Custom authorization server (
/oauth2/default): under Security > API > your server > Claims, add a claim namedgroups, included in the ID Token, Always, value type Groups with a filter. No extra scope needed.
Then 'groupsClaim' => 'groups' and a groupMap keyed on group names. Keep the filter narrow: Okta fails the request if more than 100 groups match. If the app's client authentication is set to Client secret post, add 'tokenAuthMethod' => 'client_secret_post'.
Keycloak#
Create a client with Client authentication on (confidential, authenticator Client ID and Secret) and Standard flow enabled. Add the redirect URI to Valid redirect URIs and, for logoutAtProvider, your $logoutRedirect address to Valid post logout redirect URIs. Setting PKCE method to S256 is recommended.
$this->providers['keycloak'] = [
'label' => 'Company login',
'issuer' => 'https://keycloak.example.com/realms/your-realm',
'clientId' => env('KEYCLOAK_CLIENT_ID'),
'clientSecret' => env('KEYCLOAK_CLIENT_SECRET'),
];- Issuer.
https://host/realms/<realm>; installations from before Keycloak 17, or with a custom relative path, have/authbefore/realms. - Leave "Exclude Issuer From Authentication Response" off (client Advanced settings). Keycloak advertises that it always names itself in the redirect (RFC 9207), so a redirect without it is refused as a possible mix-up attack.
- Signature algorithm. The ID token must be signed with RS256/384/512 or ES256/384/512 (the default is RS256). PS256 and EdDSA aren't supported yet.
- Groups. Add a Group Membership mapper with token claim name
groupsand Add to ID token on. Values are full paths like/adminsunless you turn Full group path off, so keygroupMapaccordingly. - Realm roles normally sit in
realm_access.rolesin the access token only. Add a User Realm Role mapper with claim nameroles(no dot), Multivalued and Add to ID token on, then use'groupsClaim' => 'roles'. - Client authentication. If the client's allowed authentication method is restricted to form posts, add
'tokenAuthMethod' => 'client_secret_post'.
Auth0#
Create a Regular Web Application. Add the redirect URI under Allowed Callback URLs and your $logoutRedirect address under Allowed Logout URLs. Auth0's issuer ends with a slash; with a custom domain, use that domain.
$this->providers['auth0'] = [
'label' => 'Auth0',
'issuer' => 'https://your-tenant.auth0.com/',
'clientId' => env('AUTH0_CLIENT_ID'),
'clientSecret' => env('AUTH0_CLIENT_SECRET'),
];- Signing algorithm. Under Advanced Settings > OAuth, the JSON Web Token signature algorithm must be RS256. Apps created through the Management API default to HS256, which is refused (HMAC tokens can be forged by anyone holding the client secret).
- Client authentication. Under Credentials, use Client Secret (Basic), or set
'tokenAuthMethod' => 'client_secret_post'to match Client Secret (Post). - Logout. For
logoutAtProvider, turn on RP-Initiated Logout End Session Endpoint Discovery in the tenant's advanced settings (on by default for tenants created since November 2023); without it Auth0 publishes no logout endpoint. - Roles. Auth0 only allows namespaced custom claims. Add them with a post-login Action, e.g.
api.idToken.setCustomClaim('https://example.com/roles', event.authorization.roles), then'groupsClaim' => 'https://example.com/roles'. - Emails. For social and enterprise connections, Auth0 passes on whatever
email_verifiedthe upstream provider says. If you allow many connections, consider'linkByEmail' => 'never'orrequiredClaims.
Any OpenID Connect provider#
Authentik, Zitadel, AWS Cognito, Ping, OneLogin, Dex and others work the same way. The issuer is the address that /.well-known/openid-configuration sits under, as the provider writes it in that document (a missing or extra trailing slash is tolerated). Everything else (endpoints, signing keys, supported algorithms) is read from there.
- Connecting an account sends
prompt=loginandmax_age=0, which obliges a spec-following provider to reportauth_time. If yours never does, set'requireAuthTime' => false; if it doesn't understandprompt=login, setreauthParams(e.g.['prompt' => 'select_account']). - If the provider adds extra audiences to ID tokens on purpose, list them in
trustedAudiences; otherwise such tokens are refused, as OpenID Connect requires. - Without a
clientSecretthe app acts as a public client relying on PKCE alone, which some providers allow for web apps (Entra doesn't).
Provider options#
Set these per provider. Anything you leave out comes from $defaults in your config, then from the package's own defaults. Settings are checked strictly: a misspelled name or a wrong type is an error (shown by oidc:check and in the log), never quietly ignored.
Connection
| Option | Default | What it does |
|---|---|---|
type | generic | entra for Microsoft Entra ID; anything else is a standard OpenID Connect provider. |
issuer | required* | The provider's issuer URL. *Not used with type = entra. |
tenant | required* | Entra: tenant id or domain, organizations, common or consumers. |
authority | login.microsoftonline.com | Entra: sign-in host, for national clouds. |
clientId | required | From the app registration. |
clientSecret | empty | From the app registration. Empty = public client (PKCE only), where the provider allows it (Entra doesn't). |
tokenAuthMethod | auto | client_secret_basic or client_secret_post, for providers that enforce the method registered for the app. auto picks Basic when the provider offers it. |
label | SSO / Microsoft | Button text: "Sign in with label". |
scopes | openid profile email | Requested scopes. openid is always added. |
authParams | [] | Extra authorization parameters, e.g. ['prompt' => 'select_account'] or ['domain_hint' => 'example.com']. The parameters the package sets itself (state, nonce, redirect_uri, scope, response_type, response_mode, PKCE) are refused. |
redirectUri | /oidc/<key>/callback | Override the callback URL (it must still reach this package's callback route). |
algorithms | all six | Accepted ID token algorithms: RS256, RS384, RS512, ES256, ES384, ES512. Never widened beyond this list. |
userinfo | true / false | Fetch claims the ID token left out from the userinfo endpoint (only with a Bearer access token). They never override the ID token, and an email's verified flag never comes from a different source than the email. |
trustedAudiences | [] | Extra ID token audiences to accept besides your client id. Any other audience is refused, as OpenID Connect requires. |
Who gets in
| Option | Default | What it does |
|---|---|---|
register | true | Create a Shield user on first sign-in. false = only people who already have an account (or get linked). |
allowedTenants | [] | Entra with several tenants: only these tenant ids may sign in. |
allowedDomains | [] | Only people whose trusted email is in these domains (exact match). Checked on every sign-in and link. |
requiredClaims | [] | Claims that must have one of the given values, e.g. ['hd' => 'example.com'] or ['groups' => ['staff', 'contractors']]. Checked on every sign-in and link. |
linkByEmail | verified | Whether a first sign-in may attach to an existing account with the same email: verified, trusted or never. See trusted emails. |
Users and groups
| Option | Default | What it does |
|---|---|---|
subjectClaim | sub / oid | Claim that identifies a person. Only use a claim people can't change themselves. |
usernameClaim | none | Fill Shield's username from a claim, e.g. preferred_username, if that username is free. |
defaultGroup | true | false = don't add new users to Shield's default group. |
groupsClaim | none / roles | Claim holding groups or roles. |
groupMap | [] | Claim value => Shield group, e.g. ['App.Admin' => 'admin']. |
groupTenants | [] | Entra with several tenants: whose roles and groups to honour. |
syncGroups | false | Also remove mapped groups people no longer have. |
logoutAtProvider | false | /oidc/logout also ends the session at the provider. |
loginActions | false | Run Shield's configured login action (e.g. Email2FA) for SSO sign-ins too, as Shield does for magic links. Off: the provider handles MFA. |
reauthParams | per provider | Authorization parameters that force a fresh sign-in when connecting an account. Default prompt=login + max_age=0; Entra prompt=login; Google prompt=select_account. |
requireAuthTime | true / false | Fail a fresh-sign-in check when the provider sends no auth_time. On by default (the spec requires it with max_age); off for Entra and Google, which don't send it by default. |
Defaults marked "x / y" differ by provider: the second value is Entra's (and for requireAuthTime, Google's too).
App-wide settings#
| Property | Default | What it does |
|---|---|---|
$routePrefix | oidc | URL prefix of the package's routes. |
$failureRedirect | /login | Where a failed sign-in goes (a path relative to app.baseURL). |
$logoutRedirect | / | Where people land after /oidc/logout (a path). |
$clockSkew | 120 | Seconds of clock difference tolerated in token times. |
$cacheTtl | 3600 | Seconds discovery documents and signing keys are cached. |
$stateTtl | 600 | Seconds a sign-in may take at the provider. |
$httpTimeout | 10 | Timeout for calls to the provider, in seconds. |
$allowLocalHttp | false | Allow a plain-http provider on localhost, for development only. |
$httpClient | CurlHttpClient | Class implementing HttpClient, e.g. to use a proxy. |
$userResolver | UserResolver | Class that turns claims into users; see Customizing. |
$defaults | see above | Defaults for every provider. Your values are merged over the package's. |
Routes#
| Method and path | Name | Purpose |
|---|---|---|
GET /oidc/<provider>/login | oidc-login | Starts sign-in. ?return=/path picks where to land (local paths only). |
GET /oidc/<provider>/callback | oidc-callback | Where the provider sends people back. Register this URL. |
POST /oidc/<provider>/link | oidc-link | Signed-in users connect a provider. Use oidc_link_form(). |
GET|POST /oidc/logout | oidc-logout | Signs out, and at the provider too with logoutAtProvider. |
The prefix comes from $routePrefix. Unknown or misconfigured providers answer 404.
Helpers#
Load them with helper('oidc'), or add 'oidc' to Config\Autoload::$helpers.
oidc_buttons(?string $returnTo = null): string
"Sign in with …" links for every configured provider, inside <div class="oidc-buttons">; each link has the classes oidc-button oidc-button-<key> btn btn-outline-secondary, so it fits Shield's Bootstrap views. Misconfigured providers are skipped.
oidc_login_url(string $provider, ?string $returnTo = null): string
The URL that starts sign-in, for building your own buttons.
<a class="btn btn-primary" href="<?= esc(oidc_login_url('entra', '/dashboard'), 'attr') ?>">
Sign in with your work account
</a>oidc_link_form(string $provider, ?string $returnTo = null, ?string $label = null): string
A small POST form a signed-in user submits to connect a provider. It includes csrf_field() and a per-session token. The button reads "Connect label" unless you pass your own. See Connecting a provider.
Commands#
php spark oidc:setup [-f]
Creates app/Config/ShieldOidc.php, extending the package config, with commented examples. Refuses to overwrite an existing file unless you pass -f.
php spark oidc:check [provider]
Checks every provider (or one), using the provider's live settings, and exits non-zero when one needs attention. Run it after every configuration change.
Provider: entra
Type Microsoft Entra ID (tenant contoso.onmicrosoft.com)
Issuer https://login.microsoftonline.com/1111.../v2.0
Signing keys 6 published; algorithms RS256
Client 6666... (with secret)
Scopes openid profile email
Identifies users by iss + oid
New accounts created on first sign-in
Link by email verified
Groups claim "roles" -> admin, developer (synced)
Register this redirect URI with the provider:
https://example.com/oidc/entra/callback
All providers look good.It also warns when, for example, any Microsoft account could create an account, a multi-tenant groupMap is being ignored, linkByEmail is trusted where that's risky, or users are identified by a claim other than sub/oid.
Events#
| Event | Arguments | When |
|---|---|---|
oidcLogin | User $user, array $claims, string $provider | Someone signed in through a provider. (When a Shield action comes first, Shield fires its own login once it's done instead.) |
oidcRegister | User $user, array $claims, string $provider | A new user was created. |
oidcLink | User $user, array $claims, string $provider | A provider identity was attached to an existing user. |
oidcFailed | string $provider, OidcException $e | A sign-in or link failed. $e->getDetail() has the reason. |
// app/Config/Events.php
Events::on('oidcRegister', static function ($user, array $claims, string $provider) {
// e.g. copy the person's name into your profile table
model(ProfileModel::class)->insert(['user_id' => $user->id, 'name' => $claims['name'] ?? null]);
});Shield's own login, logout and register events fire as usual, so your existing listeners see SSO users too. Shield's failedLogin isn't fired (its payload is password credentials); use oidcFailed. Sign-ins are recorded in auth_logins with id_type oidc, which Shield's lastLogin() includes.
Messages#
What people see when sign-in fails, and what usually causes it. Copy Language/en/Oidc.php to app/Language/<locale>/Oidc.php to translate or reword them. The exact reason is always in the log.
| Key | Message | Usual cause |
|---|---|---|
accessDenied | Sign-in was cancelled, or your account isn't allowed to use this app. | The person cancelled, or the provider refused them (e.g. not assigned to the app in Okta or Entra). |
stateInvalid | This sign-in link isn't valid any more. | Callback opened twice, in another browser, or after the session ended. |
stateExpired | Sign-in took too long. | More than $stateTtl at the provider. |
tokenInvalid | We couldn't verify your sign-in. | Token check failed: wrong tenant, an untrusted extra audience, an HMAC-signed token, clock skew, a missing or stale auth_time when connecting, or a token endpoint error. |
providerUnavailable | Single sign-on isn't working right now. | Provider unreachable, or its settings don't match the configuration. |
providerError | The sign-in provider reported a problem. | The provider returned an error other than a cancellation. |
noAccount | There's no account for you here yet. | register is off and nothing matched. |
emailTaken | An account with your email address already exists… | A matching account exists but the email isn't trusted, linkByEmail is never, or the account isn't activated. |
domainNotAllowed | Accounts from your organization can't sign in here. | allowedDomains or requiredClaims didn't match. |
alreadyLinked | That account is already connected to a different user. | Connecting an identity another user already has. |
linkNeedsLogin | Sign in first, then connect your account. | Link request without a signed-in user. |
notActivated | Your account hasn't been activated yet. | Shield requires activation and this account hasn't completed it. |
loginBlocked | Your account needs another step before you can sign in… | A Shield action is pending but Shield's routes aren't registered, or Shield refused the login. |
registerFailed | Your account couldn't be created. | Database error while creating the user. |
Accounts and matching#
After the token is verified, the package decides which Shield user this is. People are identified by issuer + subject (for Entra, the tenant-specific issuer + oid), stored as an oidc row in Shield's auth_identities table. Email addresses never identify anyone on their own.
requiredClaims, then allowedDomains (trusted email only)oidc_link_form()linkByEmail isn't never. Otherwise: "An account with your email already exists."registerNew users get no usable password and join Shield's default group; Shield's register event fires as well as oidcRegister. If their email is trusted it's stored in Shield's email identity, so $user->email works. Without a trusted email they have no email identity and sign in only through the provider; note that Shield's php spark shield:user commands only list users with an email identity, so manage those users in code (model(UserModel::class)). Banned users are refused before anything about their account changes.
When an email is trusted
An email address from the provider is used (to link an existing account, to check allowedDomains, or as a new account's email) only when the provider vouches for it, in the way that provider documents:
| Provider | The email is trusted when |
|---|---|
| Microsoft Entra ID | the xms_edov optional claim is true (the issuing tenant verified the domain). Entra's email can hold an address an administrator typed in, even in a single tenant. |
it's an @gmail.com address, or email_verified is true and the hd claim is present. | |
| Everyone else | email_verified is true. |
| Any provider | you set linkByEmail => 'trusted': you vouch that its users can't set their own address. |
Linking also needs the local account's address to be verified, by Shield. That's true when registration is closed (Config\Auth::$allowRegistration = false: accounts were made by administrators), or when registration requires an email Activator and the account completed it. With Shield's default open registration and no activation, anyone could have registered someone else's address and waited for that person's first SSO sign-in, so email linking is off and people connect the provider from their account instead.
linkByEmail | First sign-in, account with same email exists |
|---|---|
verified (default) | Links when both addresses are verified as above. |
trusted | Treats every email from this provider as verified (the local account still has to be). |
never | Never links automatically; people connect from their account instead. |
If an unverified email could link accounts, anyone able to set their email at the provider (a tenant admin in multi-tenant Entra, an old personal Google account with a former employer's address, a provider with unverified sign-up) could sign in as someone else here just by using that person's address.
Connecting a provider
People with a password account can connect a provider from a page they reach while signed in, such as their account settings:
<?php helper('oidc') ?>
<?= oidc_link_form('entra', '/account') ?>This works whatever their email at the provider is. Because connecting changes who can sign in to an account, it's protected more than a sign-in:
- it's a POST with a per-session token, so another site can't trigger it with a link or hidden form;
- the provider is asked for a fresh sign-in (
reauthParams), so a provider session already open in the browser can't be linked silently:prompt=loginandmax_age=0by default,prompt=loginfor Entra, the account picker for Google (the only options each documents); auth_timeis checked: it must be fresh, and a provider that should send it but doesn't is refused (Okta, Keycloak and Auth0 send it; for Entra, add theauth_timeoptional claim to get the check);- an identity already connected to another user can't be taken over.
Shield actions (2FA, activation)
SSO sign-ins follow Shield's own magic-link login. If Shield already has an action waiting for the user, such as an Email2FA code or an account activation, they're sent to Shield's action page to finish it; Shield then completes the login and fires its login event. Starting a new login action for SSO users is off by default, since the provider normally handles MFA; turn it on per provider with 'loginActions' => true. Both need Shield's routes (service('auth')->routes($routes), added by shield:setup).
Roles and groups#
Map the provider's roles or groups onto Shield groups, and Shield's permissions do the rest.
'groupsClaim' => 'roles',
'groupMap' => ['App.Admin' => 'admin', 'App.Developer' => 'developer'],
'syncGroups' => true,- Mapped groups are added on every sign-in. With
syncGroups, mapped groups the person no longer has are removed. - Groups that aren't in
groupMapare never touched, so groups you assign inside the app stay. - Nothing is removed when the claim is missing entirely: a dropped scope shouldn't demote everyone.
- A list claim matches values exactly. A string claim is one value:
"Help Desk Admins"doesn't matchAdmins. - The Shield groups must exist in
Config\AuthGroups; unknown ones are logged and skipped.
Entra app roles
Use app roles: define them under App registration > App roles, then assign people or groups under Enterprise applications > your app > Users and groups. They arrive in the roles claim, which is the default groupsClaim for Entra. Group claims also work, but Entra replaces them with a reference once someone is in more than about 200 groups; the package logs a warning and leaves groups alone when that happens.
Each organization assigns your app's roles to its own people, so any of them could give themselves App.Admin. Roles and groups from multi-tenant Entra are ignored unless the token's tenant is listed in groupTenants:
'tenant' => 'organizations',
'groupMap' => ['App.Admin' => 'admin'],
'groupTenants' => ['11111111-2222-3333-4444-555555555555'], // your own tenantLogging out#
Shield's /logout signs people out of your app only; their provider session stays, so the next "Sign in with …" may not ask for a password. /oidc/logout signs out of your app and, with logoutAtProvider, also ends the provider session (OpenID Connect RP-Initiated Logout). The provider then sends people to $logoutRedirect, which you must register with it (oidc:check prints the address): Entra under Authentication as a redirect URI; Okta under Sign-out redirect URIs; Keycloak under Valid post logout redirect URIs; Auth0 under Allowed Logout URLs. Google has no provider logout. The package sends id_token_hint (required by Okta), except to Entra, which documents logout_hint instead (from the login_hint optional claim).
<a href="<?= site_url('oidc/logout') ?>">Sign out</a>Customizing#
How users are created
Extend UserResolver and point $userResolver at your class. Every step is a protected method:
- newUserData()
- Fields for a new user. Add your own columns here.
- trustedEmail()
- Decides whether the provider's email can be trusted (per provider).
- localEmailVerified()
- Decides whether Shield vouches for an existing account's email.
- checkRequiredClaims()
- The
requiredClaimsrule. - checkDomain()
- The
allowedDomainsrule. - syncGroups()
- Group mapping and sync.
- identityExtra()
- What's kept in the identity's
extracolumn.
namespace App\Libraries;
use DaemonLabs\ShieldOidc\Provider;
use DaemonLabs\ShieldOidc\UserResolver;
class MyUserResolver extends UserResolver
{
protected function newUserData(Provider $provider, array $claims, ?string $trustedEmail): array
{
return parent::newUserData($provider, $claims, $trustedEmail) + [
'display_name' => is_string($claims['name'] ?? null) ? $claims['name'] : null,
];
}
}
// app/Config/ShieldOidc.php
public string $userResolver = \App\Libraries\MyUserResolver::class;Add any new column to Shield's UserModel::$allowedFields (Shield's docs explain extending the user model).
HTTP
To route provider calls through a proxy or add logging, implement DaemonLabs\ShieldOidc\Http\HttpClient (two methods, get() and post(), returning ['status' => int, 'body' => string]) and set $httpClient. The class is constructed with one argument, $httpTimeout in seconds. Keep TLS verification on and don't follow redirects.
Buttons and messages
Style .oidc-buttons and .oidc-button, or build your own with oidc_login_url(). Override the view by creating app/Views/… and rendering it yourself, and translate messages with app/Language/<locale>/Oidc.php.
What's verified#
Every ID token goes through these checks, in this order, before any claim is used (OpenID Connect Core 1.0, section 3.1.3.7):
- Format. Compact JWS, strict base64url, a JSON object payload.
- Algorithm. One of RS256/384/512 or ES256/384/512, allowed for this provider, and advertised by it; never widened.
noneand HMAC (HS256…) are always refused, as arecritheaders. - Signature. Against the provider's published keys only; keys in the token header (
jwk,jku,x5u,x5c) are never used. The key must match the token'skid, key type, curve,use/key_opsand any issuer it's scoped to. RSA keys under 2048 bits or with unsafe exponents are refused. - Issuer. Exactly the provider's issuer; for multi-tenant Entra, the token's own tenant, matching its
tidand your tenant rules. - Audience. Must include your client id; any other audience must be in
trustedAudiences.azp, when present, must be your client id (errata set 2 of the spec). - Time.
exp,iatandnbf, numeric and finite, within$clockSkew. - Nonce. Must equal the one sent with this sign-in.
- Subject. Present, at most 255 characters (the spec's limit), and printable ASCII (stricter than the spec, which allows any ASCII; it keeps control characters out of logs and storage).
- Authentication time. When a fresh sign-in was asked for (
max_age, or connecting an account),auth_timemust be recent enough, and present where the provider is obliged to send it.
Provider metadata is checked too: the discovery document's issuer must match your configuration, every endpoint must be https, and signing keys are refetched only when a new key id appears, at most every 5 minutes (Microsoft's guidance). If the provider is briefly unreachable after the cache expires, the last good discovery document and keys (kept for a week) are used. The redirect's iss (RFC 9207) is checked before anything else in it, error responses included, and the access token is only sent to userinfo when its type is Bearer.
Attacks and defences#
| Attack | Defence |
|---|---|
| Forged or tampered ID token | Signature verified against the provider's keys; weak keys and alg: none/HMAC refused. |
| Stolen authorization code | PKCE: the code is useless without the verifier in the original session. |
| Login CSRF (signing a victim into the attacker's account) | State bound to the browser session that started the sign-in, single use. |
| Token replay | Nonce per sign-in; expiry checked. |
| Mix-up between providers | State bound to one provider, per-provider callback URLs, RFC 9207 iss checks. |
| Account takeover by email (nOAuth) | Matching on issuer + subject; email used only when trusted. |
| Pre-registration squatting | Untrusted emails never stored on new accounts; email linking only into accounts whose address Shield verified (closed registration, or activation). |
| Stale or third-party email (Google) | Google emails count only for @gmail.com or with hd, as Google advises. |
| Another tenant granting itself admin | Multi-tenant Entra roles ignored outside groupTenants. |
| Forced account linking | POST with a session token, fresh provider sign-in, auth_time checked. |
Open redirect via return | Local paths only, resolved against app.baseURL. |
| Code leaking via Referer | The callback answers with Referrer-Policy: no-referrer (RFC 9700). |
| Session fixation | Shield regenerates the session on login. |
| Log injection, information leaks | Provider text flattened and capped in logs; people see fixed messages only. |
| Configuration typos | Unknown or mistyped settings are errors, not silently dropped restrictions. |
Hardening your app#
- Prefer a single Entra tenant, or
allowedTenants/requiredClaimsto limit who can sign in. Runoidc:checkand read its warnings. - Turn off other ways in if SSO should be the only one. Users with an email can still use Shield's magic-link login (
Config\Auth::$allowMagicLinkLogins, on by default), which is also Shield's forgot-password flow, and doesn't go through your provider's MFA or account removal. - Mind session lifetime. Existing sessions and remember-me tokens outlive removal at the provider until they expire. Keep them short for sensitive apps.
- Keep secrets in
.env, never in committed config. Rotate client secrets before they expire (Entra secrets expire after at most two years). - Use HTTPS everywhere and set
app.baseURLto your https address. - Watch the log for
Shield OIDClines and theoidcFailedevent.
Troubleshooting#
Start with php spark oidc:check, then look in writable/logs for lines starting Shield OIDC [provider].
- Button goes back to /login
- Shield's
sessionfilter is blocking/oidc/*; see Installation. - Redirect URI mismatch
- Register exactly what
oidc:checkprints, and setapp.baseURLto your https address (andapp.indexPageto empty if you use pretty URLs). - Unknown setting "…"
- A typo in a provider setting; compare with Provider options.
- Issuer does not match
- The
issuermust be the provider's, including any path (e.g./realms/x,/oauth2/default); a trailing slash is tolerated. For Entra, checktenantand, in national clouds,authority. - No iss parameter (Keycloak)
- Turn off the client's Exclude Issuer From Authentication Response.
- HMAC-signed token
- Switch the provider to RS256 (Auth0: Advanced Settings > OAuth).
- Untrusted audience
- The provider adds audiences to ID tokens; list them in
trustedAudiencesif that's intended. - No auth_time
- When connecting an account, the provider didn't report the sign-in time. Set
requireAuthTime => falsefor providers that never send it. - "Cancelled, or not allowed"
- Usually the person isn't assigned to the app at the provider (Okta, Entra with assignment required).
- Token issuer not expected (Entra)
- The account is from a tenant your
tenant/allowedTenantsdoesn't allow, or a personal account onorganizations. - No usable ID token algorithm
- Your
algorithmslist and what the provider offers don't overlap. - "Account already exists"
- Sign in with the password and connect the provider, or see trusted emails.
- Groups not applied
- Check the claim name, that the Shield groups exist, and for multi-tenant Entra,
groupTenants. - Token "issued in the future"
- The server clock is off; enable NTP, or raise
$clockSkewslightly. - Provider unreachable
- Outbound HTTPS from the server is blocked, or DNS fails; try
curlto the issuer from the server.
Limitations#
- No SAML yet; OpenID Connect only.
- PS256 and EdDSA signatures, encrypted ID tokens, and front- and back-channel logout aren't supported.
- Client authentication is by client secret only; certificate credentials (
private_key_jwt), which Microsoft prefers for production, are planned. - Entra apps with custom signing keys (claims mapping) aren't supported.
- Shield's login actions (e.g. Email2FA) aren't started for SSO sign-ins unless
loginActionsis on: MFA normally belongs to the provider. - Entra (by default) and Google don't send
auth_time, so for them the fresh-sign-in check when connecting an account relies on their prompt. For Entra, add theauth_timeoptional claim. - Users without an email identity don't appear in Shield's
shield:usercommands.
Testing and contributing#
composer install
composer testThe suite runs a fake identity provider with real RSA and EC keys and covers token forgeries, weak and malformed keys, key rotation, Entra tenant rules, account takeover attempts, group mapping, and the full browser round trip through Shield. Continuous integration runs it on PHP 8.1 to 8.4, and on the oldest supported CodeIgniter and Shield versions.
Report security problems privately through GitHub's Report a vulnerability button on the repository's Security tab, not in public issues.