Skip to content

feat: HTTP Basic Authentication (RFC 7617) identity support #189

Description

@guicassolato

Summary

Add first-class HTTP Basic Authentication to the PPE as a new identity resolver, kind: identity/basic, running on the identity.resolve hook alongside identity/jwt and identity/api-key. It extracts Authorization: Basic <base64(user-id:password)> per RFC 7617 / the RFC 7235 framework, decodes and splits the credential into a user-id and a password, looks the user-id up against a credential directory, and verifies the password against the stored verifier in constant time — then projects the record onto the subject/client/workload identity slots through the same ClaimMapper every identity plugin shares.

The deliberate contrast with API keys (next section) is the point of the epic: Basic Auth is not an opaque key, and modelling it as one — which is the only option in Kuadrant today — loses per-user password hashing, .htpasswd interoperability, and indexed-by-username lookup.

Motivation — the parity gap

Kuadrant's AuthPolicy / Authorino has no first-class Basic Auth. The documented way to do it (authorino/docs/user-guides/http-basic-authentication.md) is to bend the API-key feature:

  • Each credential is a Kubernetes Secret whose api_key entry holds base64(username:password) — the entire Authorization value after the scheme, password included.
  • authentication.apiKey.credentials.authorizationHeader.prefix: Basic strips the Basic scheme and the remaining base64 blob is matched, byte-for-byte, against the stored api_key values.
  • To act on the username, the operator writes a gjson expression that re-does the work the server already discarded: authorization.@extract:{"pos":1}|@base64:decode|@extract:{"sep":":"}.

That works, but it is a workaround with real costs:

  1. The password is the key. Because the index key is base64(user:password), the record is bound to one exact password. You cannot store a hashed password (bcrypt/argon2/apr1), so a secret leak hands over usable plaintext-equivalents; and a password rotation is a new record, not an update.
  2. No .htpasswd interoperability. The near-universal on-disk format for Basic Auth (see below) is username:password-hash, keyed by username with a per-user hash. The API-key model cannot consume it.
  3. Lookup is keyed by the secret, not the subject. You cannot index by username (the natural, stable key), and every authorization rule that needs the username must re-decode the header using gjson syntax.

PPE already resolved the API-key half of this (#108). This epic does the Basic half properly, as its own identity method with its own API surface — while reusing the machinery #108 already built wherever the semantics actually match.

What HTTP Basic Auth actually is (the spec, and what matters for the RFE)

Basic is defined by RFC 7617 (the scheme) on top of RFC 7235 (the Authorization / WWW-Authenticate framework). The details below are the ones the implementation has to get right.

1. The credential on the wire

Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==
                └┬──┘ └──────────────┬──────────────┘
               scheme          base64(user-id ":" password)
  • The value is "Basic" 1*SP token68, where token68 is the base64 of the octet string user-id ":" password. The scheme name is case-insensitive and may be followed by any run of spaces (1*SP).
  • Decoding is the first real step — the server base64-decodes, then splits on the first :. Everything before it is the user-id; everything after (including any further colons) is the password.
  • The user-id MUST NOT contain a colon (the first colon is the delimiter) and neither user-id nor password may contain control characters. A Basic resolver must reject a decoded credential that violates this rather than guessing.
  • Charset. The octet→character mapping is historically unspecified (often Latin-1). RFC 7617 adds the charset auth-param whose only legal value is UTF-8: when set, the client is expected to send the user-pass as UTF-8 of NFC-normalised text. The user-id/password follow the PRECIS UsernameCasePreserved / OpaqueString profiles (RFC 7613) — i.e. the user-id is case-preserved, not case-folded, and the password is opaque.

2. The challenge (the 401)

On a missing/failed credential the server returns 401 with a required challenge:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="Corp API", charset="UTF-8"
  • realm is mandatory and is a bare equality-compared label for the protection space. charset="UTF-8" is optional and advisory.
  • PPE must be able to attach this header to the denial response. This is a PPE implementation requirement, not a differentiator against the Authorino workaround — Authorino already emits WWW-Authenticate: Basic realm="<config-name>" for the API-key-modelled case. The only RFC-optional nicety to add on top is charset="UTF-8". See Open questions — denial-response wiring, for the seam PPE needs.

3. Credentials are long-lived and replayed

Clients cache Basic credentials for the protection space (same origin, path-prefixed from the authenticated URI) and pre-emptively replay them on every subsequent request — there is no session, no expiry, no logout. Two consequences for PPE:

  • Every request carries the password. The verify path is on the hot path for every call, so the per-request cost (base64 decode + index probe + one password-hash verification) matters — hence indexed-by-username lookup and a verify cache (below).
  • TLS is non-negotiable. RFC 7617 §4 is blunt: Basic transmits the password in cleartext (base64 is not encryption) and "SHOULD NOT be used (without enhancements such as HTTPS)". The docs for this feature must say so; the engine itself sees only already-terminated requests, but the guidance belongs here.

4. Server-side storage — .htpasswd and password hashes

The near-universal on-disk format (Apache htpasswd, nginx, many others) is a flat text file, one record per line, colon-separated, keyed by username:

alice:$2y$10$Nt3.../bcrypt...
bob:$apr1$salt$md5hash
svc:{SHA}base64sha1==

The second field is a password verifier, and the scheme is self-describing by prefix:

Prefix Scheme Notes
$2y$, $2a$, $2b$ bcrypt the modern default; recommend as the only writing target
$apr1$ Apache MD5 (apr1) legacy, weak — support for reading, discourage
$argon2id$ … argon2 newer httpd; strong
{SHA} base64 SHA-1, unsalted legacy, weak
(DES crypt / plaintext) crypt(3) / cleartext do not support — 8-char truncation / plaintext

The design must treat the stored value as a verifier to check a password against, never as a lookup key. This is the structural break from the API-key model and the reason for a username index.

5. Why unpack the username and index on it (and why Authorino doesn't)

Authorino indexes on base64(user:password) because, to the API-key feature, the whole thing is the opaque key. A first-class Basic implementation should instead:

  1. base64-decode, split on first : → (user-id, password);
  2. probe the directory by user-id (an O(1) hash-map lookup, like FileDirectory's SHA-256 index, but keyed by the username string);
  3. verify password against the record's stored verifier in constant time, with a dummy verification on a miss so a non-existent user and a wrong password take the same time (no username-enumeration timing oracle).

This is what unlocks per-user hashing and .htpasswd, and keeps the hot-path cost one index probe + one verify regardless of directory size — the optimisation the RFE calls out, and the one Authorino's model structurally cannot do.

Implementation notes

How this differs from identity/api-key

Basic and API key are both lookup-and-project identity methods, but they are not the same shape, and the config surface must reflect that:

Dimension identity/api-key (#108) identity/basic (this epic)
Credential model one opaque token two parts: user-id + password
Transport framing prefix: = scheme + leader (e.g. Bearer sk-oai-), leader kept scheme: = scheme only (Basic); credential is base64, decoded
Lookup key digest of the whole presented key the user-id
Stored material sha256:<hex> of the key (an index, not a verifier) a password verifier (bcrypt/argon2/apr1) + optional fields
Match operation map probe on digest (no compare needed) map probe on user-id + constant-time password verify
On-disk format PPE YAML keys: [{hash, ...}] .htpasswd (first-class) or PPE YAML keyed by user
Denial challenge no IANA auth scheme to name (opaque key) 401 + WWW-Authenticate: Basic realm=… (mandatory scheme + realm)
Subject id mapped from record fields defaults to the user-id; fields still mappable

So this is a new plugin kind with its own config:, not a flag on the api-key plugin. A prefix: Basic on identity/api-key continues to exist as the Authorino-parity escape hatch (and is actually how you'd replicate the upstream behaviour today), but identity/basic is the recommended path.

Reuse from identity/api-key

Most of the machinery this plugin needs already exists in crates/builtins/src/plugins/identity_api_key/ (built for #108). The reuse map:

Directly reusable (same semantics):

  • The identity-resolve hook + plugin anatomy. Same HookHandler<IdentityHook> on HOOK_IDENTITY_RESOLVE (crates/ppe-core/src/identity/hook.rs), same two-file shape (hook type in ppe-core, impl+factory in builtins), same mode: sequential + on_error: fail enforcement a denying identity plugin needs.
  • CredentialLocation scheme-stripping. identity_api_key/credential.rs:172 (strip_gate / strip_prefix_ignore_case) already implements RFC 7235 case-insensitive scheme matching with 1*SP — exactly what stripping Basic needs. For Basic the credential after the scheme is then base64-decoded rather than kept verbatim. This should be lifted to the shared praxis_policy_core::identity::Credential that PR feat!: typed credential locations for JWT identity plugin #96 is adding, so both plugins share one extractor.
  • PresentedKey (directory.rs:41) — the zeroized, no-Debug/Display/Serialize credential buffer. The password must live in exactly this (the user-id, being an index key, is text; the password is the secret).
  • ClaimMapper (crates/ppe-core/src/identity/mapping/) — projects the record's JSON onto subject/client/workload, identically to JWT and api-key, so subject.roles means the same thing whatever authenticated it.
  • The directory + refresh/staleness machinery. FileDirectory's ArcSwap index, on-request-path refresh_secs revocation window, and max_staleness_secs fail-closed ceiling (file_directory.rs) transfer almost verbatim to a username-indexed / .htpasswd backend.
  • HttpDirectory (http_directory.rs) + CachingDirectory (cache.rs) — the "delegate verification to a service" backend and the positive/negative verify cache.

The KeyDirectory trait needs a deliberate extension, not naive reuse. Today lookup(presented) -> Option<KeyRecord> (directory.rs:107) takes one opaque credential and returns the record — the match is "found the digest". Basic is lookup-by-user-id then verify-password: the directory must either (a) expose a verify(user_id, password) -> Option<KeyRecord> sibling that owns the constant-time compare and the dummy-verify-on-miss, or (b) return a record carrying the verifier for the resolver to check. (a) is preferred — the password must not surface outside the backend, and only the backend knows the hash scheme. This is the main net-new abstraction.

Terminology — don't reach for delegator/*. In PPE, delegator names a specific thing: the token.delegate hook family and delegator/oauth (crates/builtins/src/plugins/delegator_oauth/), which mints outbound downstream credentials (RFC 8693). That is not the right fit for Basic Auth — Basic is inbound identity resolution. The reusable pattern here is the api-key plugin's delegation of the lookup to a pluggable backend (provider.kind: file | http, behind KeyDirectory): Basic should keep the same provider-selects-a-backend shape, not the delegator/* hook family.

Net-new:

  • base64 STANDARD decode (api-key uses none; JWT uses URL_SAFE_NO_PAD). base64 = "0.23" is already a workspace dep, just not enabled for a Basic feature yet (crates/builtins/Cargo.toml).
  • a password-verifier dependency (e.g. bcrypt / argon2 / an apr1 reader).
  • an .htpasswd parser backend (none exists in the tree today).
  • a TokenSource::Basic variant (crates/ppe-core/src/identity/payload.rs).
  • WWW-Authenticate challenge emission on denial (today the string appears only in a test fixture, emitting Bearer).

Proposed config shape

plugins:
  - name: corp-basic
    kind: identity/basic
    hooks: [identity.resolve]
    mode: sequential            # enforced — a denying identity plugin must block
    on_error: fail
    config:
      credential:               # shared Credential shape (PR #96)
        kind: header
        name: Authorization
      scheme: Basic             # default; RFC 7235 case-insensitive, 1*SP after.
                                # scheme ONLY — no "leader", unlike api-key's prefix.
      charset: UTF-8            # advisory; decode user-pass as UTF-8 + NFC
      realm: "Corp API"         # REQUIRED for the WWW-Authenticate challenge

      provider:
        kind: htpasswd          # first-class .htpasswd backend
        path: /etc/ppe/users.htpasswd
        refresh_secs: 30        # revocation window (same semantics as api-key file)
        max_staleness_secs: 300 # fail-closed ceiling
        # allow_weak_schemes: false   # reject apr1/{SHA} reads unless opted in

      # --- or a username-indexed YAML backend carrying extra identity fields ---
      # provider:
      #   kind: file
      #   path: /etc/ppe/users.yaml     # users: [{username, password_hash, roles, teams, expires_at?}]
      #   refresh_secs: 30

      # --- or delegate verification to a service (sends user-id + password) ---
      # provider:
      #   kind: http
      #   url: https://userdir.internal/validate
      #   cache: { positive_ttl_secs: 60, negative_ttl_secs: 5 }

      record_map: {}            # defaults subject.id <- user-id; fields still mappable
      role: user                # user | client | caller_workload
      expiry: enforce           # honour record expires_at where present

htpasswd backend, minimal case: no record_map needed — subject.id becomes the user-id and that is the whole identity, which is the common Basic deployment.

Denial produces a 401 whose response carries WWW-Authenticate: Basic realm="Corp API", charset="UTF-8", and one of these codes (extraction codes shared with other identity plugins per #108):

  • auth.missing_credential — no Authorization header
  • auth.empty_credential — header present, nothing after the scheme
  • auth.malformed_credential — base64 fails / no : / control chars / bad UTF-8
  • auth.user_unknown — user-id not in the directory (see enumeration note)
  • auth.bad_password — user-id found, password verify failed
  • auth.key_expired — record past expires_at
  • auth.directory_unavailable — backend could not answer (fail-closed)
  • auth.mapping_failed — record could not be projected

Enumeration note: auth.user_unknown vs auth.bad_password is useful for operators but is itself an oracle if it reaches the client. The client must always see an indistinguishable 401 + challenge; the distinction lives only in logs/metrics. Pair this with the dummy-verify-on-miss so timing does not leak it either.

Security requirements (carried from #108, plus Basic-specific)

  • TLS required — document that Basic ships the password in cleartext; base64 is not encryption.
  • Password lives in Zeroizing, never written to raw_credentials (so it can't be forwarded upstream), never Debug/Display/Serialized, never logged — the invariant PresentedKey already enforces.
  • Constant-time verification and dummy-verify-on-miss to defeat username-enumeration via timing.
  • Store hashes, not passwords. The verifier is bcrypt/argon2 (apr1/{SHA} read-only and gated behind an opt-in; DES-crypt/plaintext unsupported).
  • Fail closed when the directory is unavailable; atomic record-set refresh (whole index swapped, failed reload keeps serving the previous set).
  • Enforce expires_at where the record carries it (file/htpasswd have no native expiry; the YAML backend can add one).
  • Reject control chars, colons in the user-id, oversized headers, and non-base64 input at the edge.

Acceptance criteria

  • kind: identity/basic registers behind a new basic Cargo feature and via install_builtins (facade re-export + register_builtin_plugins! row).
  • Extracts and base64-decodes Authorization: Basic …, splits on the first colon, enforces no-colon-in-user-id, rejects control chars and bad UTF-8; charset: UTF-8 applies NFC.
  • Case-insensitive scheme match with 1*SP (reusing/extending the api-key CredentialLocation logic; lifted to the shared Credential from feat!: typed credential locations for JWT identity plugin #96).
  • .htpasswd backend reading bcrypt ($2*$) and argon2; apr1/{SHA} read-only behind an opt-in; DES-crypt/plaintext rejected at load.
  • Username-indexed YAML backend carrying a verifier + projectable fields + optional expires_at.
  • HTTP backend that delegates verification to a service, with positive/negative caching (reusing HttpDirectory/CachingDirectory).
  • Lookup is O(1) in directory size (username hash index); constant-time password verify with dummy-verify-on-miss.
  • refresh_secs revocation window + max_staleness_secs fail-closed ceiling, atomic whole-index swap (as FileDirectory).
  • Record projected via the shared ClaimMapper; subject.id defaults to the user-id; verifier/index fields never reach the projected claims.
  • Denial emits 401 + WWW-Authenticate: Basic realm=…[, charset="UTF-8"]; the client sees an indistinguishable 401 for unknown-user vs bad-password; the distinction is log/metric-only.
  • Password held in Zeroizing, never in raw_credentials, never logged.
  • TokenSource::Basic added; denial codes shared with the other identity plugins where the condition is the same.
  • Docs page + a loaded-fixture worked example (so it can't drift).

Open questions

  1. KeyDirectory extension vs new trait. Add verify(user_id, password) to KeyDirectory (keeps one directory abstraction, backend owns the compare) or introduce a Basic-specific CredentialDirectory? Recommendation: extend KeyDirectory so the file/http backends stay shared, with the password compare living in the backend. Confirm.
  2. Denial-response wiring for the challenge. What is the clean seam for an identity plugin to set WWW-Authenticate on the denial? Does it ride PPE's existing customizable-denial-response path, or does the identity hook need a dedicated "challenge" output on IdentityPayload/PluginViolation? This is the main cross-cutting dependency.
  3. Multiple Basic resolvers on one route. Unlike api-key (populations split by prefix leader), Basic has no sub-scheme to disambiguate populations, so stacking two identity/basic instances means "try each directory by username". Allowed? Ordered? Or one-per-route with multi-realm left out of v1?
  4. PRECIS normalisation depth. Full UsernameCasePreserved/OpaqueString (RFC 7613) enforcement, or the pragmatic subset (UTF-8 + NFC + control-char reject) most servers actually implement? Over-normalising can lock out real users; under-normalising can split one user across two index keys.
  5. Weak-hash policy. Read apr1/{SHA} by default (htpasswd compatibility) or require allow_weak_schemes: true? Writing is out of scope (PPE verifies, it doesn't mint), so this is purely a read-time gate.
  6. Kubernetes Secret backend. Epic: API-key identity support #108 deferred a label-selected-Secret adapter to a separate component. Same decision here (keep it a separate adapter exposing the directory contract), or in scope?

Activity

  1. added theissue type on Oct 9, 2026
  2. changed the title [-]Epic: HTTP Basic Authentication (RFC 7617) identity support[/-] [+]feat: HTTP Basic Authentication (RFC 7617) identity support[/+] on Oct 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions