Skip to content

Consent-JWT / Consent-Id / PSD2-CERT header lookups are case-sensitive, so they silently fail over HTTP/2 #2914

Description

@hongwei1

Title: Consent-JWT / Consent-Id / PSD2-CERT header lookups are case-sensitive, so they silently fail over HTTP/2

Several request-header lookups in APIUtil.scala compare the header's
name field with == against a fixed-case literal, instead of doing a
case-insensitive comparison:

// obp-api/src/main/scala/code/api/util/APIUtil.scala
def getConsentJWT(requestHeaders: List[HTTPParam]): Option[String] = {
  requestHeaders.toSet.filter(_.name == RequestHeader.`Consent-JWT`).toList match {
    case x :: Nil => Some(x.values.mkString(", "))
    case _ => requestHeaders.toSet.filter(_.name == RequestHeader.`Consent-Id`).toList match {
      case x :: Nil => Some(x.values.mkString(", "))
      case _ => None
    }
  }
}

The same pattern appears for Consent-ID (a second, differently-cased
variant of the same header exists a few lines below Consent-Id),
PSD2-CERT, and TPP-Signature-Certificate.

Why this breaks under HTTP/2: RFC 7540 §8.1.2 requires HTTP/2 header
field names to be sent in lowercase on the wire. Any HTTP/2 client — which
is most modern HTTP clients by default when talking to a server that
advertises h2 via ALPN — sends consent-jwt, not Consent-JWT. The
== comparison against the mixed-case literal never matches, so
hasConsentJWT/getConsentJWT return false/None even though the
header is present with the correct name and value. The request then falls
through to whatever the next auth branch is, and — in our case — ends up
with a generic OBP-20001: User not logged in instead of ever reaching
Consent.checkConsent/applyConsentRulesCommon, which made this
confusing to diagnose: none of the consent-specific debug logging in
ConsentUtil.scala ever fired, because that code path was never entered.

Reproduction, against a real instance with consents.allowed=true, a
consent already created via POST /obp/v5.1.0/my/consents/IMPLICIT and
successfully answered via POST /obp/v3.1.0/banks/BANK_ID/consents/CONSENT_ID/challenge
(consent status ACCEPTED, verified independently):

# Over HTTP/2 (curl's and most clients' default for an ALPN-h2 server)
curl "https://<host>/obp/v5.1.0/users/current/user_id" \
  -H "Consent-JWT: <the accepted consent's jwt>" \
  -H "Consumer-Key: <consumer key>"
# -> HTTP 401 {"code":401,"message":"OBP-20001: User not logged in. Authentication is required!"}

# Identical request, forced to HTTP/1.1
curl --http1.1 "https://<host>/obp/v5.1.0/users/current/user_id" \
  -H "Consent-JWT: <the same jwt>" \
  -H "Consumer-Key: <consumer key>"
# -> HTTP 200 {"user_id":"..."}

Same consent, same JWT, same headers — the only difference is the wire
casing HTTP/1.1 vs HTTP/2 puts on the header name, and that alone flips
the result between "not logged in" and a correctly resolved user.

Suggested fix: compare header names case-insensitively, the way
getRequestHeader() a few lines below already does it correctly
(_.name.toLowerCase == name.toLowerCase). Applies to at least
getConsentJWT, getConsentIdRequestHeaderValue, `getPSD2-CERT`,
and the Consent-ID variant — worth a broader grep across APIUtil.scala
for the same filter(_.name == RequestHeader...) pattern to catch any
others.

Environment: reproduced against openbankproject/obp-api:latest,
commit b5a5765a185d173392c9d9c8fe36a4381097d954 (current develop HEAD
as of 2026-09-16), Kubernetes (k3s), behind an ingress-nginx TLS
termination that negotiates HTTP/2 with clients — but the bug is in the
header comparison itself and is not specific to this deployment; any
HTTP/2 client hitting these endpoints on any OBP-API instance should
reproduce it the same way.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions