solr-proxy's OAuth2 client never sends a Bearer header to /oauth/userinfo
Area: solr-proxy / OAuth2 client / ADR 014 authenticated path Raised during: Session 2026-08-18 (first real browser walkthrough of the SAML → OAuth2 → proxy chain) Jira: (add when available) Priority: High — blocks the authenticated (private-collection) path through the proxy entirely; the public/anonymous path is unaffected
Issue
Logging into Drupal via SAML and completing the OAuth2 authorization-code flow against
solrproxy (/oauth/authorize → /oauth/token) works correctly end to end — the proxy's
auth.php gets a real access token back with HTTP 200. But the very next call,
getResourceOwner() (which hits /oauth/userinfo to read the sub claim), fails:
Unexpected Value Invalid response received from Authorization Server. Expected JSON.
Access log for that request shows why — it's not a JSON error response, it's a redirect:
POST /oauth/token -> 200 (token exchange succeeded)
GET /oauth/UserInfo -> 302 (redirected to /user/login)
A 302 to Drupal's login page means the request was treated as fully anonymous — not
"bad token", but "no credentials presented at all".
Why
auth.php constructs the OAuth2 client as a vanilla
League\OAuth2\Client\Provider\GenericProvider. In league/oauth2-client, the base
AbstractProvider::getAuthorizationHeaders($token) — the method responsible for adding
Authorization: Bearer <token> to authenticated requests — has no default implementation
and returns []:
// AbstractProvider.php
protected function getAuthorizationHeaders($token = null)
{
return [];
}
Provider-specific subclasses (Google, GitHub, etc.) override this to add the Bearer header.
GenericProvider does not override it. So fetchResourceOwnerDetails() →
getAuthenticatedRequest() sends the /oauth/userinfo request with no Authorization
header whatsoever. Drupal's simple_oauth.userinfo route requires _role: authenticated
with _auth: ['oauth2']; with no Bearer token to authenticate against, the request is
anonymous, and Drupal's default anonymous-access-denied handling for an HTML-negotiated
route is a redirect to /user/login, not a JSON 403.
Confirmed empirically on dev-0 (not just from source):
- A garbage Authorization: Bearer garbage-token-value header → clean 401 from the
oauth2 auth provider (proves the route does consume the header when one is sent, and
case sensitivity in /oauth/UserInfo vs /oauth/userinfo is a red herring — both
behave identically once a header is present).
- The real access token issued to a real authenticated non-admin test user (uid 600, via
the new dev test-IdP — see below) is well-formed in the oauth2_token entity table
(type=access_token, correct client=2 (solrproxy), correct user=600,
10-minute expiry) — the token itself isn't the problem, it's simply never sent.
Not a Drupal/simple_oauth bug. The endpoint paths are already correct
($OAUTH_ROOT = /oauth, not /oauth2, per Spike 10's finding). This is purely
missing client-side behavior in how the proxy uses GenericProvider.
Why this has never been caught before
Per the project's own notes (project-solr-proxy-cicd session log, 2026-08-12/13), the
real browser-driven OAuth2 flow — SAML login → /oauth/authorize → proxy → /oauth/userinfo
— had never been exercised end to end. All prior validation either used a synthetic
Redis token written by hand, or stopped short of a real browser session. This session was
the first time anyone actually walked the whole chain with a real user, and it surfaced
immediately.
Fix options (not yet decided)
- Subclass
GenericProvider(or configure it, if the installed version supports an option for this) to overridegetAuthorizationHeaders():protected function getAuthorizationHeaders($token = null) { return $token ? ['Authorization' => 'Bearer ' . (string) $token] : []; } - Or bypass
getResourceOwner()entirely and issue the/oauth/userinfoGET manually with an explicitAuthorization: Bearerheader inauth.php.
Either is a small, contained change in solr-proxy/proxy/auth.php (provider instantiated
at line 59, getResourceOwner() called at line 107) — in this monorepo, not a
separate repo. ADR 014 forked the D11 proxy into solr-proxy/ (commit 1b8e682); the
uvalib/mandala-solr-proxy name in the original version of this note was a mistake —
that repo doesn't exist. Don't confuse this with shanti-uva/mandala-solr-proxy, the
unrelated D7 proxy ADR 014 explicitly leaves untouched.
This is not a D11-introduced regression. shanti-uva/mandala-solr-proxy's own
auth.php has the identical code — vanilla GenericProvider, no
getAuthorizationHeaders() override, same call shape to D7's $OAUTH_ROOT/UserInfo —
confirmed by reading the legacy repo directly (mandala-legacy/mandala-solr-proxy,
league/oauth2-client: dev-master in its composer.json, same empty base-class method
in its own vendored copy). The bug was inherited from D7, not introduced during the
fork, and was presumably just as latent there — never exercised end-to-end with a real
browser either. Fixed 2026-08-19: solr-proxy/proxy/src/BearerGenericProvider.php
(new file, autoloads via the existing PSR-4 mandala\oauth\ → src/ mapping already in
composer.json) subclasses GenericProvider with the header override; auth.php now
instantiates that instead of the base class. Verified with a standalone script
confirming getAuthenticatedRequest() now carries Authorization: Bearer <token>.
What's already proven and doesn't need re-testing
- SAML login through the new dev test-IdP → real Drupal session (non-admin user, uid 600).
/oauth/authorize→/oauth/tokenfull authorization-code exchange, with a realsolrproxyclient secret, realstateround-trip, realautomatic_authorization.- The issued access token is correctly scoped to the right user/client in Drupal's own token storage.
Only the client-side "fetch resource owner details" step is broken. Once fixed, the
remaining unproven link is the proxy's own Redis visibility-token read for a real
OAuth2-authenticated session (previously only proven with a hand-written Redis key, per
project-solr-proxy-cicd's 2026-08-13 note) — that should be the next thing to check
once this is fixed.
Related
- Spike 10 findings — endpoint paths,
openid-on-client_credentialscrash,automatic_authorizationpermission requirement - ADR 014 — hybrid Solr proxy design
- oauth-openid-scope-client-credentials-crash.md — a different simple_oauth OAuth2 gap found the same week as Spike 10
- oauth2-signing-keys-not-persisted-across-deploy.md — a second, independent OAuth2 defect found in the same session, fixed live on dev-0 but not yet fixed at the infrastructure level