# Troubleshooting Guide

This guide is for WordPress administrators running **Open Access SSO** (version 2.1.4 and later). It explains the plugin's two diagnostic logs and the Test Connection tool, walks through the most common SSO problems and their fixes for both protocols the plugin supports (SAML 2.0 and OpenID Connect), and ends with a checklist of what to capture before asking for help.

The plugin is privacy-first: it sends **no** telemetry, analytics, or phone-home traffic and loads **no** external CDN assets. For a SAML IdP, the only outbound HTTP requests are the admin-initiated IdP-metadata fetch and the opt-in per-IdP certificate-rotation cron that re-fetches that same metadata URL. For an OpenID Connect provider, three more outbound calls exist during login itself — discovery, JWKS refresh, and (if enabled) UserInfo — see [section 3](#3-openid-connect-issues). All configuration is stored in `wp_options`; the SP private keys are encrypted at rest.

A few conventions used throughout:

- "SP" is your site as a SAML Service Provider; "RP" is your site as an OpenID Connect Relying Party. Either way, "IdP" is your identity provider — Entra ID / Azure AD, Okta, Google Workspace, OneLogin, Auth0, ADFS, Keycloak, Shibboleth, SimpleSAMLphp, PingFederate, NetIQ Access Manager (now OpenText), or any other SAML 2.0 IdP or OpenID Connect provider (OP).
- The admin lives under a single top-level menu, **Open Access SSO**. Tabs referenced below (Dashboard, Identity Providers, Service Provider, Role Mapping, Access Control, Audit Log, Debug Log) are tabs on that page.
- Settings shown with a backtick name (for example `require_audience`) are stored keys; the UI label is given alongside where one exists.

---

## 1. Diagnostic tools

### 1.1 The Debug Log

The Debug Log records the plugin's own view of each SAML exchange — what it received, what it decided, and why a message was rejected. It is the single most useful artifact when an SSO login fails.

**Enable it.** Go to **Dashboard → General Settings**, tick **Enable Debug Logging** ("Log SAML events for troubleshooting"), and save. This sets `debug_mode` on. Enabling it also reveals a new **Debug Log** tab (the tab is only present while `debug_mode` is on). A "Debug Logging Active" badge appears on the Dashboard as a reminder.

**View it.** Open the **Debug Log** tab. Entries are shown most-recent-first in a table with five columns:

- **Time (UTC)** — when the event was recorded (always UTC, not site time).
- **Event** — a short machine code such as `acs_received`, `response_error`, `slo_request_error`, or `sha1_signature_accepted`.
- **Message** — a human-readable description (for failures, this is the underlying validation error).
- **IP** — the remote address that triggered the event.
- **Context** — an expandable "Show details" block with structured data (for example the IdP slug, and on a decryption failure the key-transport algorithm and OAEP digest the IdP used).

**What gets logged.** Events are written at the public endpoints and inside the validator: the ACS endpoint receiving a response (`acs_received`), a failed response (`response_error`, with the exact validation error in context), AuthnRequest issuance, SLO request/response handling and errors, encrypted-message diagnostics (the declared key-transport algorithm and OAEP digest), and warnings when a relaxed setting is honored (`sha1_signature_accepted`, and the legacy-decryption warning). Nothing is logged at all while `debug_mode` is off.

**Retention.** The log keeps the **most recent 200 entries** and is stored in `wp_options` (not autoloaded). Use the **Clear Log** button to empty it. Turn `debug_mode` back **off** once you have finished diagnosing — it is a troubleshooting aid, not a setting to leave on permanently.

**Privacy note.** The Debug Log can contain SAML attribute values (names, email addresses, group memberships) and IP addresses. All context values are sanitized (HTML/script stripped) before storage, but the data is still personal data and is visible to anyone with `manage_options`. Clear the log when you are done, and **redact** these values before sharing the log with anyone. The plugin never logs the SP private key or the bypass secret.

### 1.2 The Audit Log

The Audit Log is a separate, longer-lived record aimed at security review rather than protocol debugging. It is a database-backed table (not options) that records who logged in, when, from where, and the outcome.

**Enable it.** Go to **Dashboard → Enabled Modules**, tick **Audit Log**, and save. This adds the **Audit Log** tab (only present while the module is enabled).

**What it records.** One row per significant event, by `event_type`:

- `login_success` — a user logged in via SSO.
- `login_failed` — an SSO login attempt failed (the underlying error is included).
- `login_denied` — login was refused by policy (for example deny-unmapped or an `oasso_allow_login` filter).
- `role_changed` — a user's roles changed as a result of role mapping.
- `logout` — an SSO user logged out.

Each row stores the event type, message, user, username, IdP slug, and timestamp. The tab provides search/filtering, pagination, **CSV export**, manual **prune**, and **Retention Settings**: `audit_retention_days` (Retention Period — 30/60/90/180/365 or Forever; default 90) and `audit_max_rows` (Maximum Entries; default 10000, clamped to a minimum of 100). A daily cron prunes old rows. CSV export is formula-injection-safe (cells beginning with `=`, `+`, `-`, `@` are neutralized), so the file is safe to open in a spreadsheet.

**Debug Log vs. Audit Log.** Use the **Debug Log** to find out *why a single SSO attempt failed* (it has the raw validation errors). Use the **Audit Log** to answer *who logged in and what happened over time*.

### 1.3 Test Connection

Test Connection lets an administrator run a complete SSO round-trip to the IdP and inspect exactly which attributes come back — **without** creating a login session or changing any user.

**Run it.** On the **Identity Providers** tab, use the Test link for the IdP you want to check. You must be logged in as an administrator (`manage_options`); the test endpoint is admin-only and nonce-protected.

**What it does.** For a SAML IdP, it issues a normal signed AuthnRequest, you authenticate at the IdP as usual, and the IdP posts the response back to the ACS endpoint. The plugin runs the **full** validation pipeline (signature, conditions, audience, expiry, replay, decryption) — so a test that *fails* tells you about the same problems a real login would hit. On success, instead of logging anyone in, it stores the returned **NameID**, **NameID format**, **SessionIndex**, and the full **attribute set** and redirects you back to the Identity Providers list with the result expanded. No WordPress session is created, no user is created or modified, and no roles are applied.

For an OpenID Connect provider, Test Connection runs the same **authorization code flow with PKCE** a real login uses — you authenticate at the provider, it redirects back to the plugin's callback, and the plugin exchanges the code for tokens, verifies the ID token's signature and claims, and (if UserInfo is enabled) fetches and merges it — the identical pipeline described in [section 3](#3-openid-connect-issues). It stores the resulting claim set the same way SAML stores its attribute set. This is the intended way to learn a provider's exact claim names — do not guess them.

Use Test Connection to confirm the IdP is actually sending the attributes/claims you expect (for example a `groups` or `email`/`mail` attribute) before you build attribute mappings or role rules against them.

> Note on browsers and cookies: the test result is tied to your admin identity at the moment you start the test, so it does not depend on your auth cookie surviving the cross-origin POST back from the IdP. If a test ever reports "Test mode requires an active admin session," start the test again from the admin (do not bookmark or reuse an old test link).

---

## 2. Common issues and fixes

> For any login failure, the first step is the same: enable `debug_mode` (§1.1), reproduce the failure, then read the latest `response_error` entry. The **Message** column contains the precise reason — the fixes below map those reasons to actions.

### 2.1 SSO login fails right after upgrading the plugin

**Symptom:** logins that worked before an upgrade now fail; the Debug Log shows an error mentioning **AudienceRestriction** or **expiry boundary**.

**Cause:** versions 2.1.x enable two assertion tighteners by default. They reject assertions that older, more permissive versions accepted:

- `require_audience` (Service Provider tab, default **on**) rejects an assertion whose `AudienceRestriction` is missing or does not name this SP. The error reads: *"SAML assertion has no AudienceRestriction, so it cannot be confirmed as issued for this Service Provider…"*
- `require_assertion_expiry` (Service Provider tab, default **on**) rejects an assertion that carries no enforceable expiry (`NotOnOrAfter`). The error reads: *"SAML assertion has no expiry boundary…"*

**The right fix** is to make the IdP conform: configure it to send an `AudienceRestriction` whose value equals your SP Entity ID (shown on the Service Provider tab), and to include a `NotOnOrAfter` on the assertion. A SAML-conforming IdP sends both.

**Temporary relaxation** (only for a non-conforming IdP you cannot fix immediately): go to the **Service Provider** tab and untick **Require audience restriction** and/or **Require assertion expiry**. These weaken replay protection, so treat them as stop-gaps and re-enable them once the IdP is corrected.

### 2.2 "oaep decoding error" / encrypted-assertion fails to decrypt

**Symptom:** the IdP encrypts the assertion (or the NameID) and login fails; the Debug Log shows a session-key unwrap failure, and the context block names an OAEP digest such as `…xmlenc-sha256`.

**Cause and fix:** modern IdPs (notably NetIQ Access Manager, now OpenText) wrap the session key with **RSA-OAEP using a SHA-256 digest**. PHP's built-in OpenSSL can only unwrap OAEP with SHA-1, which produces the classic "oaep decoding error". Open Access SSO 2.1.4 unwraps the session key through the bundled **phpseclib** library, which honors the exact OAEP digest and MGF the IdP declares (SHA-1/256/384/512), so OAEP-SHA-256 works out of the box. If you are seeing this on 2.1.4, check:

- **The IdP is encrypting to the correct certificate.** It must encrypt to your **SP encryption certificate** (or signing certificate if you did not configure a separate one), set on the Service Provider tab. If the IdP has an old/wrong SP cert, the unwrap will fail. Re-export your SP metadata from the Service Provider tab and re-import it at the IdP.
- **The error message itself** is intentionally generic — *"Failed to decrypt the SAML session key…"* — so it cannot leak a padding oracle. The useful detail (key-transport algorithm, OAEP digest, MGF) is in the Debug Log **context** block.

**Legacy algorithms:** the plugin refuses **RSA-1.5** key transport and **3DES** data encryption by default (errors: *"unsupported or insecure key-transport algorithm. RSA-OAEP is required"*). The correct fix is to reconfigure the IdP to RSA-OAEP + AES-GCM/CBC. As a last resort you can set `allow_legacy_decryption_algorithms` (import-only; see §3), which re-enables RSA-1.5 and 3DES and writes a warning to the Debug Log each time it is honored. This is discouraged — RSA-1.5 is padding-oracle-prone and 3DES is obsolete.

### 2.3 Signature validation failures

**Symptom:** the Debug Log shows *"Unsupported or insecure SAML signature algorithm: …"* or a signature-verification failure.

**Causes and fixes:**

- **Weak algorithm.** The plugin accepts only **RSA-SHA256/384/512**. An IdP signing with RSA-SHA1 (or HMAC/DSA) is refused. Reconfigure the IdP to sign with SHA-256 or stronger. If a legacy IdP genuinely cannot, you can set `allow_sha1_fallback` (import-only; see §3) to accept RSA-SHA1; it logs a warning whenever honored. Prefer fixing the IdP.
- **Wrong / changed certificate.** Signature verification runs against the IdP certificate stored on the **Identity Providers → edit** screen. If the IdP rotated its signing certificate, verification will fail until you update the stored cert — see §2.7.
- **Clock skew.** Errors such as *"SAML assertion is not yet valid"* or *"…has expired (NotOnOrAfter exceeded)"* are usually clock drift between your server and the IdP, not signature problems. Open **Dashboard → General Settings → Clock Skew Tolerance** (`clock_skew`, default 120 seconds, range 0–600) and, if your clocks are loosely synchronized, raise it modestly. The better fix is to enable NTP on the WordPress host so the clocks agree.

### 2.4 InResponseTo / replay errors

**Symptom:** the Debug Log reports an `InResponseTo` mismatch, or a logout error such as *"LogoutResponse InResponseTo does not match any pending logout request (possible replay or expired request)."*

**Cause:** the plugin records each AuthnRequest/LogoutRequest ID in a replay store and requires the IdP's response to reference one of them; an ID is consumed once. Legitimate triggers for this error:

- **A stale or re-submitted response** — for example using the browser Back button to resend a SAML response, or refreshing the ACS POST. Start a fresh login; do not replay an old response.
- **IdP-initiated SSO** where the IdP sends an unsolicited assertion with no `InResponseTo`. Open Access SSO supports IdP-initiated SLO; for IdP-initiated *login*, prefer SP-initiated flow (start from your site's login button) so the request/response pair lines up.
- **A very slow round-trip** that outlived the request's TTL (derived from twice the clock skew, minimum 600 seconds). Retry the login; if it recurs, the clock skew may be too tight (§2.3).
- **Load-balanced / multi-server setups with an inconsistent object cache.** The replay store is database-backed with a transient fallback; ensure all app servers share the same database and a coherent cache so a request recorded on one node is visible to the node that receives the response.

### 2.5 First/last name or other attributes not populating

**Symptom:** users log in, but their first name, last name, display name, or a custom field stays empty.

**Cause:** the attribute names the IdP sends do not match the **Attribute Mapping** you configured (mappings are per-IdP).

**Fix:**

1. Run **Test Connection** (§1.3) for that IdP and read the returned attribute list. Note the *exact* attribute keys (they are often URIs such as `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname`, not friendly names).
2. Go to **Identity Providers → edit → Attribute Mapping** and map each WordPress field (email, first name, last name, display name, nickname, or custom user_meta) to the exact SAML attribute key from step 1.
3. Re-test. Note that **email** is special: by default the email is set at account creation and not changed on later logins unless **Allow IdP to Update User Email** (`allow_email_overwrite`) is on.

### 2.6 Roles not assigned (or the user gets the wrong role)

**Symptom:** SSO users all land on the default role, or are denied login, even though you wrote role rules.

**Causes and fixes:**

- **The rule does not match the attribute value.** Use **Test Connection** to see the real value of the attribute your rule keys on (for example `groups`). On the **Role Mapping** tab, confirm the rule's SAML attribute, match type (**exact / contains / regex**), and match value against that real value. Rules are evaluated in **priority order** (ascending); the first match wins. An invalid regex is dropped on save.
- **Admin-level role won't apply.** By design, role mapping will **not** grant a role with admin-level capabilities (`manage_options`, `edit_users`, …) unless **Allow Administrator-Level Roles via SSO** (`allow_privileged_roles`, Dashboard → General Settings) is on. With it off, a user who would map to such a role gets the default role instead. Granting admin via SSO is a deliberate decision — enable the setting only if you accept it; otherwise grant admin manually.
- **User is denied entirely.** If **Deny login if no rule matches** is enabled for that IdP (Role Mapping tab) and no rule matches, the user is refused (you will see `login_denied` in the Audit Log). Either add a matching rule, set a sensible **Default Role**, or turn off deny-unmapped.
- **Roles get overwritten / wiped.** By default each login overwrites the user's roles from the mapping. Turn on **Preserve existing roles (merge instead of overwrite)** on the Role Mapping tab if you assign some roles in WordPress and want SSO to add to them rather than replace them.

### 2.7 Certificate expired or rotated

**Symptom:** logins that worked suddenly fail signature verification, or you see an admin warning about pending certificates.

**Background:** Open Access SSO can monitor the IdP's metadata URL on a schedule and detect when the signing certificate changes, then apply your chosen **rotation strategy**. You can also **pin** the certificate so a silent change is refused.

**Fixes by configuration:**

- **Manual update.** If you manage the cert by hand, paste the IdP's new X.509 certificate on **Identity Providers → edit** and save. The fingerprint (SHA-256) is recomputed automatically.
- **Pinned certificate (`cert_pinned`).** When pinning is on, signature verification deliberately fails if the fingerprint changes — this is the protection working. Update (and re-approve) the new certificate to restore logins.
- **Rotation strategy.** Per IdP you can set `cert_rotation_strategy` to `manual`, `auto_trust` (adopt the new cert automatically — do **not** use on a pinned IdP), `grace_period` (trust old + new for `cert_grace_days`, default 7, range 1–90), or `require_approval`. With **require approval**, a newly detected cert is queued and an **admin-wide warning notice** appears ("N pending certificates awaiting approval for IdP …"); open the IdP edit screen and **Approve** the certificate to activate it.
- **Automatic check.** Set **Automatic Check** (`cert_rotation_cron`) to daily or weekly so rotations are detected before they break logins. A daily cron also promotes/expires grace-period certificates.

The recommended hardened posture is to **pin** the certificate and pair it with **require_approval** plus an automatic check, so a rotation notifies you instead of either silently breaking or silently being trusted.

### 2.8 "SAML Response exceeds the maximum allowed size"

**Symptom:** login fails immediately and the Debug Log shows *"SAML Response exceeds the maximum allowed size"* (or the equivalent for a LogoutRequest/LogoutResponse).

**Cause:** the plugin rejects any SAML message larger than **256 KB** before decoding it, as a denial-of-service guard. Legitimate assertions are far smaller, so this usually means the IdP is packing something unexpectedly large into the assertion — most often a very large number of `groups`/role claims.

**Fixes (in order of preference):**

1. **Trim the assertion at the IdP** — release only the groups/attributes this SP actually needs. This is almost always the correct fix and improves performance too.
2. **Raise the cap** with the `oasso_max_saml_message_bytes` filter if your IdP genuinely needs more headroom. In a small must-use plugin or your theme's `functions.php`:

   ```php
   // Raise the SAML message size cap to 512 KB (default is 256 KB).
   add_filter( 'oasso_max_saml_message_bytes', function () {
       return 512 * 1024;
   } );
   ```

   Raise it only as much as you need; the cap also bounds the inflate of redirect-binding messages, so keep it conservative.

### 2.9 Locked out by Force SSO (cannot reach wp-login)

**Symptom:** **Force SSO** or **Require Login for All Pages** is on, the IdP is down or misconfigured, and you cannot reach the normal login form.

**Recovery, in order of reliability:**

1. **Emergency constant (most reliable).** Add to `wp-config.php`:

   ```php
   define( 'OASSO_BYPASS', true );
   ```

   While defined and truthy, forced SSO and the plugin's access gates are bypassed, so you can log in with a normal WordPress username/password. Remove the line after fixing the configuration. This works even if the database-stored bypass key is unavailable.

2. **Bypass key via URL.** If you set a **Bypass Secret Key** (`bypass_secret_key`, minimum 24 characters; Dashboard → General Settings) *before* the lockout, append it to any URL:

   ```
   https://example.com/wp-login.php?oasso_bypass_key=YOUR-SECRET-KEY
   ```

   A correct key sets a short-lived, HMAC-signed `oasso_bypass` cookie that carries the bypass for the rest of the session. Failed attempts are rate-limited per IP (20 per 15 minutes), so set a long, random key. Because the key must be configured beforehand, always set it **before** enabling Force SSO.

### 2.10 Restricted media is still reachable, or restricted content appears in REST/sitemap

These are content-restriction behaviors that surprise admins. Here is the **current behavior** precisely:

- **REST API, feeds, oEmbed — gated.** When the Access Control (page restriction) module is on, restricted posts/pages are filtered out of REST responses, RSS/Atom feeds, and oEmbed output for users who lack access. Restricting a post does protect its content on those delivery paths.
- **XML sitemaps — NOT gated.** The plugin does not filter the core WordPress XML sitemaps. A restricted post's **URL** may still appear in `wp-sitemap.xml` even though its **content** is protected everywhere above. If a URL must be entirely hidden, exclude it from the sitemap separately (via a sitemap/SEO plugin or `wp_sitemaps_*` filters) — restriction controls access to the content, not its listing in the sitemap.
- **Media / file restriction — proxy model.** With the Media/File Restriction module on, the plugin rewrites protected attachment URLs to a proxy endpoint (`/oasso-file/{id}`) that enforces access (admins always pass; bypass methods and bypass URLs are honored; otherwise the user must be logged in, meet the SSO-only requirement if set, and hold an allowed role).

  Since 2.4.0, files attached to a restricted page are physically moved out of the
  public uploads folder ("relocation"), so the old direct URL returns 404 for
  everyone and the bytes are only reachable through the access-checked
  `/oasso-file/` gateway. Two things to know: (1) files that were already
  protected before 2.4.0 stay at their public URLs until you run "Relocate
  existing protected media" on the Access Control page; (2) after relocating or
  restoring, purge your page cache/CDN — cached HTML still references the old
  URLs and will show broken images until it refreshes. If the plugin is
  deactivated (or the Media module is turned off), relocated files are
  unreachable — but not exposed — until you re-enable it or restore them first.

  For defense in depth:
  - Put the files in the **protected directory** (`wp-content/uploads/oasso-protected/`). Files there are served only through the proxy. Set the **Allowed Roles (Protected Directory)** (`media_protected_dir_roles`) on the Access Control tab (empty = any logged-in user). Saving (re)creates the directory.
  - At the web-server level, deny direct HTTP access to the protected directory (and to any uploads you want gated) so only the PHP proxy can read them. The plugin enforces access in PHP; it cannot prevent the web server from serving a file it is configured to serve.
  - Note: the Media/File Restriction module expects the **Access Control** (page restriction) module to also be enabled; an admin notice reminds you if it is not.

---

## 3. OpenID Connect issues

OpenID Connect logins go through a different pipeline than SAML — authorization code
exchange, ID token signature verification, claim validation, and (optionally) a
UserInfo fetch — so its failures look different and land in the Debug Log under a
different set of event names. This section covers those event names and the failure
modes that come up in real deployments; §2 above still applies to anything that isn't
protocol-specific (Force SSO lockouts, role-mapping mechanics, message size limits, and
so on).

### OpenID Connect debug log events

On a managed host with no server-level logs, the Debug Log (§1.1 — turn on `debug_mode`
first) is frequently the *only* diagnostic surface for an OpenID Connect failure. These
are the events an OIDC login can write, and what each one means:

| Event | Meaning |
|---|---|
| `oidc_op_error` | The provider refused the sign-in **before issuing a token** — it redirected back to the callback with an `error` parameter instead of a code. Usually a wrong redirect URI, client ID, or scope rejected at the authorization endpoint itself. |
| `oidc_token_exchange_failed` | The code-for-token POST to the token endpoint was rejected. Usually a wrong client secret, a client-authentication method the provider doesn't accept, or (see item 1 below) a provider-side client misconfiguration reported in a non-JSON body. |
| `oidc_login_failed` | A token was received but something afterward refused it — signature verification, claim validation (issuer, audience, nonce, expiry), or the subject claim was missing/malformed. This is a catch-all for anything that goes wrong inside the callback; it is only written while `debug_mode` is on, unlike the forced events above and below it, so if you need to see it, turn on Debug Logging *before* reproducing the failure. |
| `oidc_userinfo_failed` | A UserInfo fetch was attempted and failed (transport error, non-200, unparsable body, or a `sub` mismatch). Sign-in is refused deliberately here — the plugin will not fall back to signing you in on the ID token's claims alone, because that would silently drop UserInfo-only claims (most commonly `groups`) and could silently downgrade a user to the default role. |
| `oidc_userinfo_skipped` | `fetch_userinfo` is enabled but no UserInfo endpoint is configured for this provider. This is a configuration state, not a failure — login proceeds on the ID token's claims alone, but role mapping may be incomplete if the rules you wrote depend on a UserInfo-only claim. |
| `oidc_jwks_refresh_failed` | The plugin could not fetch the provider's signing key set (JWKS). Login continues on the previously cached key set if one exists, so this is often a leading indicator rather than an immediate login failure — investigate before the cache fully ages out. |
| `oidc_login_validated` | Success. The context records the **algorithm**, **key ID**, and — most usefully for troubleshooting — the full list of **claim names** the token (and UserInfo response, if fetched) carried. This is the quickest way to see exactly what a provider actually sends, faster than reading a raw token by hand. |

Four of these (`oidc_op_error`, `oidc_token_exchange_failed`, `oidc_userinfo_failed`,
`oidc_jwks_refresh_failed`) are written **regardless of whether Debug Logging is
on** — they are rare, bounded events that the plugin forces into the log because they
matter enough to always capture. The other three (`oidc_login_failed`,
`oidc_userinfo_skipped`, `oidc_login_validated`) only appear while `debug_mode` is on,
the same as the SAML events in §1.1.

### 1. `client jwks is missing` (HTTP 400 at the token endpoint)

**Symptom:** the token exchange fails immediately after an otherwise normal-looking
sign-in at the provider. The Debug Log shows `oidc_token_exchange_failed` with a `body`
context field containing the literal text `client jwks is missing` (not JSON — some
providers, notably NetIQ Access Manager, return this as a plain-text HTTP 400 body
rather than the `{"error": "..."}` shape most OPs use).

**Cause:** the provider's client registration has **ID token encryption enabled**. When
a client is registered to receive encrypted (JWE) ID tokens, the provider needs the
client's own public key to encrypt to — that's what it means by "jwks is missing." Open
Access SSO is a secret-authenticated confidential client with no keypair of its own, and
it **does not support encrypted ID tokens**: it only ever verifies a signed ID token.
There is nothing to configure on the plugin side that fixes this — the client simply
cannot satisfy a provider that insists on encrypting to it.

**Fix:** on the provider's client configuration, clear the **ID token encryption
algorithm** (or equivalent "encrypt ID token" setting) so it issues a signed-only ID
token. This is a provider-side setting; if you don't administer the provider yourself,
this is the one thing to ask for specifically. This exact error has cost a real
deployment an afternoon before the cause was identified — it is worth checking first,
before deeper protocol debugging.

### 2. The provider's discovery document can understate what it actually signs with

**Symptom:** OpenID Connect logins work, but only until an administrator pins the **ID
token signature algorithm** setting on the IdP edit screen to whatever the provider's
discovery document advertises under `id_token_signing_alg_values_supported` — after
which every login starts failing with *"This provider is pinned to signature algorithm
…​but the token used …"*.

**Cause:** a provider's discovery document is a *declaration*, not a guarantee, and it
can lag what the provider actually does. One real-world example: NetIQ Access Manager's
discovery document advertises `id_token_signing_alg_values_supported: ["RS256"]`, but
the tokens it actually issues are signed with **RS512**. Left unset, this is harmless —
the plugin's default is to accept **any** algorithm on its allowlist (RS256/384/512,
PS256/384/512, ES256/384/512; see
[Security and Hardening](security-and-hardening.md#5-openid-connect-280)) and simply
records whichever one a given token used. Pinning the field to what discovery claims
turns that mismatch into a hard failure on every login.

**Fix:** leave **ID token signature algorithm** at its default (**"Any supported
algorithm"**) unless you have a specific reason to pin it — for example, deliberately
rejecting a downgrade to a weaker algorithm on a provider you know signs consistently.
If a login is failing with a pinned-algorithm error, check `oidc_login_validated`
entries from before the pin was set (or a fresh Test Connection) to see the algorithm
actually in use, and either match the pin to that or remove the pin.

### 3. Claim names vary and are rarely guessable

**Symptom:** attribute mapping or role mapping rules never match anything, even though
the provider is clearly issuing tokens (`oidc_login_validated` appears in the Debug
Log).

**Cause:** unlike SAML, where this plugin ships built-in defaults for the common
Microsoft claim URIs, OpenID Connect claim names are provider-specific and often not
what you'd guess. A representative example from an LDAP-backed NetIQ deployment: email
arrives as `mail`, not `email`, alongside `cn`, custom claims such as `engFullName`,
`hebFullName`, and `emp_type`, and group membership as `groups`. Nothing about the OIDC
spec requires a provider to use `email`/`given_name`/`family_name` even when it has
perfectly good equivalent data.

**Fix:** don't guess. Run **Test Connection** (§1.3) for the IdP and read the exact
claim names back from the result, or from a `oidc_login_validated` Debug Log entry's
`claim_keys` context field — both show precisely what the provider sent for that login.
Build Attribute Mapping and Role Mapping rules against those exact names.

### 4. `redirect_uri` mismatch

**Symptom:** the sign-in at the provider appears to succeed — you authenticate, and the
browser is redirected back toward your site — but the token exchange fails with an HTTP
400 from the token endpoint (`oidc_token_exchange_failed`, typically with an
`invalid_grant` or `invalid_request` error).

**Cause:** the `redirect_uri` the plugin sends in the token exchange must match, **byte
for byte**, the one it sent in the original authorization request, and both must match
what's registered at the provider — scheme, host, port, path, query string, and any
trailing character, all identical. The plugin always uses the same value,
`https://your-site.example/?oasso_oidc_cb=1` (see
[Identity Provider Setup](identity-provider-setup.md#your-openid-connect-rp-details)),
computed from your site's home URL — so this almost always means what's registered at
the provider doesn't match that, most often because of an `http://` vs `https://`
difference, a `www.` mismatch, a non-default port, or a stray trailing slash.

**Fix:** open the IdP edit screen, copy the **Redirect URI** field exactly (use the Copy
button rather than retyping it), and confirm it is registered at the provider
character-for-character. If your site is reachable at more than one hostname (a `www`
redirect, a staging alias), make sure the one WordPress actually resolves as its home
URL is the one registered — that's the value the plugin computes and sends, regardless
of which hostname the browser used to reach the login page.

### 5. Roles are not being assigned (OIDC)

**Symptom:** SSO users all land on the default role, even though Role Mapping has rules
that should match, and the login itself succeeds (`oidc_login_validated` appears, no
error).

**Cause:** the most common cause specific to OpenID Connect is that the role rule keys
on a claim — almost always `groups` — that the provider only ever includes in the
**UserInfo** response, not in the ID token. This is the default behavior on **Entra ID**
once a user belongs to more than roughly 150 groups (Entra omits `groups` from the ID
token past that point) and on **Okta** generally (Okta commonly scopes `groups` to
UserInfo only, not the ID token, unless specifically configured otherwise). If **Fetch
the UserInfo endpoint** is turned off for that IdP, or no UserInfo endpoint is
configured at all, the login still succeeds on the ID token's claims — quietly, with no
error — and the role rule simply never sees the claim it's looking for.

**Fix:** on the IdP edit screen, confirm **Fetch the UserInfo endpoint** is enabled and
that a **UserInfo endpoint** is actually populated (run **Fetch from discovery**, or
check the `oidc_userinfo_skipped` event in the Debug Log, which fires exactly when this
is the problem). Then re-run **Test Connection** and confirm `groups` (or whichever
claim your rule uses) is present in the returned claim set before re-checking the Role
Mapping rule itself against §2.6's general causes (priority order, match type, an
invalid regex silently dropped on save).

---

## 4. Import-only escape hatches

A few relaxations have **no settings-UI checkbox** by design, because they weaken security. They are recognized only by the configuration **import** sanitizer, so you set them by importing a config JSON (Import / Export tab) that includes the key, or via a filter. Each writes a warning to the Debug Log whenever it is actually honored:

- `allow_sha1_fallback` (default off) — accept RSA-SHA1 SAML signatures (see §2.3).
- `allow_legacy_decryption_algorithms` (default off) — accept RSA-1.5 key transport and 3DES data encryption (see §2.2).

Prefer fixing the IdP over enabling either of these. Re-export your config to confirm what is currently set (secrets are omitted from a non-secret export).

---

## 5. What to capture before asking for help

When you open a support request (or a Codeberg issue), include the following. It lets a maintainer diagnose without a second round-trip — and **never** paste secrets.

- **Plugin version** (from the Plugins screen; this guide assumes 2.1.4+) and **WordPress + PHP versions**.
- **Which IdP** (Entra ID, Okta, NetIQ Access Manager, Keycloak, etc.), **which protocol** (SAML or OpenID Connect), and for SAML which **binding** (Redirect or POST) for SSO/SLO.
- **What you did and what happened** — the exact step, the on-screen error, and whether it ever worked (for example "broke after upgrading from 2.0.x").
- **Debug Log lines.** Enable `debug_mode`, reproduce the failure once, then copy the relevant rows from the **Debug Log** tab. For SAML, especially the latest `acs_received` and `response_error` entries; for OpenID Connect, the latest `oidc_op_error`, `oidc_token_exchange_failed`, `oidc_login_failed`, or `oidc_userinfo_failed` entry (see §3's event table) — and the expanded **Context** for the failure either way. Include the **Time (UTC)** and **Event** columns.
- **Audit Log row** for the attempt if relevant (`login_failed` / `login_denied`), which carries the IdP slug and outcome.
- **Test Connection result** — the returned **attribute/claim keys** (you usually only need the keys/structure, not the live values) so mapping/role problems can be reproduced.
- **Your relevant settings** — for SAML, `require_audience`, `require_assertion_expiry`, `clock_skew`, the IdP's rotation strategy/pinning; for OpenID Connect, whether **ID token signature algorithm** is pinned or left at default, and whether **Fetch the UserInfo endpoint** is enabled — and whether either import-only escape hatch is set. The **Import / Export** tab can export your config **without** secrets — do that, not a full export.

**Do NOT include**, in logs, exports, or screenshots:

- The **SP private key** or SP encryption key (encrypted at rest; never appears in a non-secret export — keep it that way), or an OpenID Connect **client secret**.
- The **bypass secret key** (`bypass_secret_key`) or any `oasso_bypass` cookie value.
- A **full SAML Response/assertion**, or a raw **ID token/access token**, containing live personal data — redact attribute/claim *values* (names, emails, group lists) and IP addresses; the attribute/claim *keys* and the error message are what matter.
- WordPress salts/keys from `wp-config.php`.

---

## Reference

- Plugin slug / text domain: `open-access-sso` · Option prefix: `oasso_` · Constant prefix: `OASSO_`
- License: GPLv2 or later
- Source: <https://codeberg.org/idgold/open-access-sso>
- Related docs: [Identity Provider Setup](identity-provider-setup.md) · [Security and Hardening](security-and-hardening.md)
- Bundled runtime dependencies (both MIT): `robrichards/xmlseclibs`, `phpseclib/phpseclib`
