Shield OIDC v1.0 GitHub

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.

PHP 8.1+CodeIgniter 4.5+Shield 1.1+MIT licenceNo extra dependencies
bash
composer require daemonlabs/shield-oidc
php spark oidc:setup
Verified, not just decoded

Every ID token's signature, issuer, audience, expiry and nonce are checked before anything in it is trusted.

No takeover by email

People are matched on a stable provider ID. An email address only counts when it can be trusted.

Entra done properly

One tenant or many, personal accounts, tenant allow-lists, and roles that other organizations can't grant themselves.

Fails closed

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, json and openssl extensions.
  • 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#

  1. Install the package and create its config file.

    bash
    composer require daemonlabs/shield-oidc
    php spark oidc:setup

    oidc:setup writes app/Config/ShieldOidc.php. The package's routes, commands, helper and messages are picked up by CodeIgniter's module discovery; there's nothing to register.

  2. Let people reach the sign-in routes. If you use Shield's global session filter to require sign-in everywhere, exempt oidc/* (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/*']],
        ],
    ];
  3. Add a provider to app/Config/ShieldOidc.php (see the provider guides), then check it:

    bash
    php spark oidc:check

    It loads the provider's settings, verifies them, and prints the redirect URI to register with the provider.

  4. 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.php view into your app, point Config\Auth::$views['login'] at the copy, and add those two lines. Sign-in errors come back as session('error'), which Shield's views already display.

Turned off Composer module discovery?

With Config\Modules::$discoverInComposer = false (or a restricted $composerPackages), CodeIgniter won't find the package's routes, commands or helper. Register its namespace yourself:

php
// 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.

Sign-in sequence The browser asks your app to sign in, is sent to the provider, signs in there, comes back with a code, and your app exchanges the code for a verified ID token before logging the person in. BrowserYour appIdentity provider 1 GET /oidc/entra/login 2 create state, nonce, PKCE verifier (kept in the session) 3 redirect to the provider 4 sign in at the provider (password, MFA, ...) 5 redirect back with code + state /oidc/entra/callback?code=...&state=... 6 GET /oidc/entra/callback 7 POST code + verifier (+ secret) 8 ID token (+ access token) 9 verify signature, issuer, audience, expiry, nonce 10 find or create the Shield user, log in 11 redirect to the app, signed in back channel: server to server over TLS
The browser never sees the tokens, and the code is useless without the PKCE verifier kept in your app's session.

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

  1. In the Entra admin centre, open Entra ID > App registrations and choose New registration.
  2. Under Supported account types, pick who can sign in. It must match your tenant setting (see the table below): Single tenant only for one organization (recommended), Multiple Entra ID tenants for organizations, Any Entra ID Tenant + Personal Microsoft accounts for common, Personal accounts only for consumers.
  3. Under Redirect URI, choose Web and paste the URI that php spark oidc:check prints, e.g. https://example.com/oidc/entra/callback.
  4. 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.
  5. Under Token configuration > Add optional claim > ID, add email and xms_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

php
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'),
        ];
    }
}
.env
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

tenantWho can sign in
tenant id or domainOnly your organization. Recommended.
organizationsAny work or school account, from any organization
commonWork, school and personal Microsoft accounts
consumersPersonal 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.

Why multi-tenant apps need care

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):

ClaimWhy
emailThe person's address, for new accounts and allowedDomains.
xms_edovSays 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_timeOptional. When present, connecting an account also checks the sign-in really was fresh.
login_hintOptional. With logoutAtProvider, signing out skips Entra's account picker.
manifest
"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:

php
$this->providers['google'] = [
    'issuer'         => 'https://accounts.google.com',
    'clientId'       => env('GOOGLE_CLIENT_ID'),
    'clientSecret'   => env('GOOGLE_CLIENT_SECRET'),
    'requiredClaims' => ['hd' => 'yourcompany.com'],
];
Use 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.com address, or email_verified is true and hd is 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_account and doesn't send auth_time by 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 logoutAtProvider signs 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").

php
$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 the groups scope: 'scopes' => ['openid', 'profile', 'email', 'groups'].
  • Custom authorization server (/oauth2/default): under Security > API > your server > Claims, add a claim named groups, 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.

php
$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 /auth before /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 groups and Add to ID token on. Values are full paths like /admins unless you turn Full group path off, so key groupMap accordingly.
  • Realm roles normally sit in realm_access.roles in the access token only. Add a User Realm Role mapper with claim name roles (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.

php
$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_verified the upstream provider says. If you allow many connections, consider 'linkByEmail' => 'never' or requiredClaims.

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=login and max_age=0, which obliges a spec-following provider to report auth_time. If yours never does, set 'requireAuthTime' => false; if it doesn't understand prompt=login, set reauthParams (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 clientSecret the 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

OptionDefaultWhat it does
typegenericentra for Microsoft Entra ID; anything else is a standard OpenID Connect provider.
issuerrequired*The provider's issuer URL. *Not used with type = entra.
tenantrequired*Entra: tenant id or domain, organizations, common or consumers.
authoritylogin.microsoftonline.comEntra: sign-in host, for national clouds.
clientIdrequiredFrom the app registration.
clientSecretemptyFrom the app registration. Empty = public client (PKCE only), where the provider allows it (Entra doesn't).
tokenAuthMethodautoclient_secret_basic or client_secret_post, for providers that enforce the method registered for the app. auto picks Basic when the provider offers it.
labelSSO / MicrosoftButton text: "Sign in with label".
scopesopenid profile emailRequested 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>/callbackOverride the callback URL (it must still reach this package's callback route).
algorithmsall sixAccepted ID token algorithms: RS256, RS384, RS512, ES256, ES384, ES512. Never widened beyond this list.
userinfotrue / falseFetch 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

OptionDefaultWhat it does
registertrueCreate 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.
linkByEmailverifiedWhether a first sign-in may attach to an existing account with the same email: verified, trusted or never. See trusted emails.

Users and groups

OptionDefaultWhat it does
subjectClaimsub / oidClaim that identifies a person. Only use a claim people can't change themselves.
usernameClaimnoneFill Shield's username from a claim, e.g. preferred_username, if that username is free.
defaultGrouptruefalse = don't add new users to Shield's default group.
groupsClaimnone / rolesClaim 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.
syncGroupsfalseAlso remove mapped groups people no longer have.
logoutAtProviderfalse/oidc/logout also ends the session at the provider.
loginActionsfalseRun Shield's configured login action (e.g. Email2FA) for SSO sign-ins too, as Shield does for magic links. Off: the provider handles MFA.
reauthParamsper providerAuthorization parameters that force a fresh sign-in when connecting an account. Default prompt=login + max_age=0; Entra prompt=login; Google prompt=select_account.
requireAuthTimetrue / falseFail 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#

PropertyDefaultWhat it does
$routePrefixoidcURL prefix of the package's routes.
$failureRedirect/loginWhere a failed sign-in goes (a path relative to app.baseURL).
$logoutRedirect/Where people land after /oidc/logout (a path).
$clockSkew120Seconds of clock difference tolerated in token times.
$cacheTtl3600Seconds discovery documents and signing keys are cached.
$stateTtl600Seconds a sign-in may take at the provider.
$httpTimeout10Timeout for calls to the provider, in seconds.
$allowLocalHttpfalseAllow a plain-http provider on localhost, for development only.
$httpClientCurlHttpClientClass implementing HttpClient, e.g. to use a proxy.
$userResolverUserResolverClass that turns claims into users; see Customizing.
$defaultssee aboveDefaults for every provider. Your values are merged over the package's.

Routes#

Method and pathNamePurpose
GET /oidc/<provider>/loginoidc-loginStarts sign-in. ?return=/path picks where to land (local paths only).
GET /oidc/<provider>/callbackoidc-callbackWhere the provider sends people back. Register this URL.
POST /oidc/<provider>/linkoidc-linkSigned-in users connect a provider. Use oidc_link_form().
GET|POST /oidc/logoutoidc-logoutSigns 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.

php
<a class="btn btn-primary" href="<?= esc(oidc_login_url('entra', '/dashboard'), 'attr') ?>">
    Sign in with your work account
</a>

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.

output
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#

EventArgumentsWhen
oidcLoginUser $user, array $claims, string $providerSomeone signed in through a provider. (When a Shield action comes first, Shield fires its own login once it's done instead.)
oidcRegisterUser $user, array $claims, string $providerA new user was created.
oidcLinkUser $user, array $claims, string $providerA provider identity was attached to an existing user.
oidcFailedstring $provider, OidcException $eA sign-in or link failed. $e->getDetail() has the reason.
php
// 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.

KeyMessageUsual cause
accessDeniedSign-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).
stateInvalidThis sign-in link isn't valid any more.Callback opened twice, in another browser, or after the session ended.
stateExpiredSign-in took too long.More than $stateTtl at the provider.
tokenInvalidWe 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.
providerUnavailableSingle sign-on isn't working right now.Provider unreachable, or its settings don't match the configuration.
providerErrorThe sign-in provider reported a problem.The provider returned an error other than a cancellation.
noAccountThere's no account for you here yet.register is off and nothing matched.
emailTakenAn 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.
domainNotAllowedAccounts from your organization can't sign in here.allowedDomains or requiredClaims didn't match.
alreadyLinkedThat account is already connected to a different user.Connecting an identity another user already has.
linkNeedsLoginSign in first, then connect your account.Link request without a signed-in user.
notActivatedYour account hasn't been activated yet.Shield requires activation and this account hasn't completed it.
loginBlockedYour 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.
registerFailedYour 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.

Are they allowed in?requiredClaims, then allowedDomains (trusted email only)
No: "Accounts from your organization can't sign in here."
Connecting a provider?Started from oidc_link_form()
Yes: attach to the signed-in user, unless another user already has this identity.
Seen this identity before?
Yes: sign in as that user (refused if banned or, when Shield requires activation, not activated).
An account with the same email?
Link it only if both addresses are verified: the provider's (trusted) and the local account's (by Shield), and linkByEmail isn't never. Otherwise: "An account with your email already exists."
Registration on?register
Yes: create an active user, with the email only if it's trusted.
Otherwise
"There's no account for you here yet."

New 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:

ProviderThe email is trusted when
Microsoft Entra IDthe 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.
Googleit's an @gmail.com address, or email_verified is true and the hd claim is present.
Everyone elseemail_verified is true.
Any provideryou 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.

linkByEmailFirst sign-in, account with same email exists
verified (default)Links when both addresses are verified as above.
trustedTreats every email from this provider as verified (the local account still has to be).
neverNever links automatically; people connect from their account instead.
Why this matters

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
<?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=login and max_age=0 by default, prompt=login for Entra, the account picker for Google (the only options each documents);
  • auth_time is 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 the auth_time optional 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.

php
'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 groupMap are 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 match Admins.
  • 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.

Multi-tenant Entra

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:

php
'tenant'       => 'organizations',
'groupMap'     => ['App.Admin' => 'admin'],
'groupTenants' => ['11111111-2222-3333-4444-555555555555'],   // your own tenant

Logging 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).

php
<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 requiredClaims rule.
checkDomain()
The allowedDomains rule.
syncGroups()
Group mapping and sync.
identityExtra()
What's kept in the identity's extra column.
php
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):

  1. Format. Compact JWS, strict base64url, a JSON object payload.
  2. Algorithm. One of RS256/384/512 or ES256/384/512, allowed for this provider, and advertised by it; never widened. none and HMAC (HS256…) are always refused, as are crit headers.
  3. 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's kid, key type, curve, use/key_ops and any issuer it's scoped to. RSA keys under 2048 bits or with unsafe exponents are refused.
  4. Issuer. Exactly the provider's issuer; for multi-tenant Entra, the token's own tenant, matching its tid and your tenant rules.
  5. 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).
  6. Time. exp, iat and nbf, numeric and finite, within $clockSkew.
  7. Nonce. Must equal the one sent with this sign-in.
  8. 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).
  9. Authentication time. When a fresh sign-in was asked for (max_age, or connecting an account), auth_time must 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#

AttackDefence
Forged or tampered ID tokenSignature verified against the provider's keys; weak keys and alg: none/HMAC refused.
Stolen authorization codePKCE: 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 replayNonce per sign-in; expiry checked.
Mix-up between providersState 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 squattingUntrusted 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 adminMulti-tenant Entra roles ignored outside groupTenants.
Forced account linkingPOST with a session token, fresh provider sign-in, auth_time checked.
Open redirect via returnLocal paths only, resolved against app.baseURL.
Code leaking via RefererThe callback answers with Referrer-Policy: no-referrer (RFC 9700).
Session fixationShield regenerates the session on login.
Log injection, information leaksProvider text flattened and capped in logs; people see fixed messages only.
Configuration typosUnknown or mistyped settings are errors, not silently dropped restrictions.

Hardening your app#

  • Prefer a single Entra tenant, or allowedTenants / requiredClaims to limit who can sign in. Run oidc:check and 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.baseURL to your https address.
  • Watch the log for Shield OIDC lines and the oidcFailed event.

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 session filter is blocking /oidc/*; see Installation.
Redirect URI mismatch
Register exactly what oidc:check prints, and set app.baseURL to your https address (and app.indexPage to empty if you use pretty URLs).
Unknown setting "…"
A typo in a provider setting; compare with Provider options.
Issuer does not match
The issuer must be the provider's, including any path (e.g. /realms/x, /oauth2/default); a trailing slash is tolerated. For Entra, check tenant and, 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 trustedAudiences if that's intended.
No auth_time
When connecting an account, the provider didn't report the sign-in time. Set requireAuthTime => false for 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/allowedTenants doesn't allow, or a personal account on organizations.
No usable ID token algorithm
Your algorithms list 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 $clockSkew slightly.
Provider unreachable
Outbound HTTPS from the server is blocked, or DNS fails; try curl to 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 loginActions is 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 the auth_time optional claim.
  • Users without an email identity don't appear in Shield's shield:user commands.

Testing and contributing#

bash
composer install
composer test

The 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.