You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
feat: HTTP Basic Authentication (RFC 7617) identity support #189
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.
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:
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.
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.
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.
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:
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:
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:
base64-decode, split on first : → (user-id, password);
probe the directory by user-id (an O(1) hash-map lookup, like FileDirectory's SHA-256 index, but keyed by the username string);
verifypassword 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:
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).
WWW-Authenticate challenge emission on denial (today the string appears only in a test fixture, emitting Bearer).
Proposed config shape
plugins:
- name: corp-basickind: identity/basichooks: [identity.resolve]mode: sequential # enforced — a denying identity plugin must blockon_error: failconfig:
credential: # shared Credential shape (PR #96)kind: headername: Authorizationscheme: 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 + NFCrealm: "Corp API"# REQUIRED for the WWW-Authenticate challengeprovider:
kind: htpasswd # first-class .htpasswd backendpath: /etc/ppe/users.htpasswdrefresh_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 mappablerole: user # user | client | caller_workloadexpiry: 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.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-decodesAuthorization: Basic …, splits on the first colon, enforces no-colon-in-user-id, rejects control chars and bad UTF-8; charset: UTF-8 applies NFC.
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
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.
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.
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?
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.
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.
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?
Summary
Add first-class HTTP Basic Authentication to the PPE as a new identity resolver,
kind: identity/basic, running on theidentity.resolvehook alongsideidentity/jwtandidentity/api-key. It extractsAuthorization: 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 sameClaimMapperevery 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,
.htpasswdinteroperability, 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:
Secretwhoseapi_keyentry holdsbase64(username:password)— the entire Authorization value after the scheme, password included.authentication.apiKey.credentials.authorizationHeader.prefix: Basicstrips theBasicscheme and the remaining base64 blob is matched, byte-for-byte, against the storedapi_keyvalues.authorization.@extract:{"pos":1}|@base64:decode|@extract:{"sep":":"}.That works, but it is a workaround with real costs:
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..htpasswdinteroperability. The near-universal on-disk format for Basic Auth (see below) isusername:password-hash, keyed by username with a per-user hash. The API-key model cannot consume it.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-Authenticateframework). The details below are the ones the implementation has to get right.1. The credential on the wire
"Basic" 1*SP token68, wheretoken68is the base64 of the octet stringuser-id ":" password. The scheme name is case-insensitive and may be followed by any run of spaces (1*SP).:. Everything before it is the user-id; everything after (including any further colons) is the password.charsetauth-param whose only legal value isUTF-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 PRECISUsernameCasePreserved/OpaqueStringprofiles (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:
realmis mandatory and is a bare equality-compared label for the protection space.charset="UTF-8"is optional and advisory.WWW-Authenticate: Basic realm="<config-name>"for the API-key-modelled case. The only RFC-optional nicety to add on top ischarset="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:
4. Server-side storage —
.htpasswdand password hashesThe near-universal on-disk format (Apache
htpasswd, nginx, many others) is a flat text file, one record per line, colon-separated, keyed by username:The second field is a password verifier, and the scheme is self-describing by prefix:
$2y$,$2a$,$2b$$apr1$$argon2id$…{SHA}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::→(user-id, password);FileDirectory's SHA-256 index, but keyed by the username string);passwordagainst 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-keyBasic and API key are both lookup-and-project identity methods, but they are not the same shape, and the config surface must reflect that:
identity/api-key(#108)identity/basic(this epic)prefix:= scheme + leader (e.g.Bearer sk-oai-), leader keptscheme:= scheme only (Basic); credential is base64, decodedsha256:<hex>of the key (an index, not a verifier)keys: [{hash, ...}].htpasswd(first-class) or PPE YAML keyed by userWWW-Authenticate: Basic realm=…(mandatory scheme + realm)So this is a new plugin kind with its own
config:, not a flag on the api-key plugin. Aprefix: Basiconidentity/api-keycontinues to exist as the Authorino-parity escape hatch (and is actually how you'd replicate the upstream behaviour today), butidentity/basicis the recommended path.Reuse from
identity/api-keyMost 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):
HookHandler<IdentityHook>onHOOK_IDENTITY_RESOLVE(crates/ppe-core/src/identity/hook.rs), same two-file shape (hook type inppe-core, impl+factory inbuiltins), samemode: sequential+on_error: failenforcement a denying identity plugin needs.CredentialLocationscheme-stripping.identity_api_key/credential.rs:172(strip_gate/strip_prefix_ignore_case) already implements RFC 7235 case-insensitive scheme matching with1*SP— exactly what strippingBasicneeds. For Basic the credential after the scheme is then base64-decoded rather than kept verbatim. This should be lifted to the sharedpraxis_policy_core::identity::Credentialthat 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/Serializecredential 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, sosubject.rolesmeans the same thing whatever authenticated it.FileDirectory'sArcSwapindex, on-request-pathrefresh_secsrevocation window, andmax_staleness_secsfail-closed ceiling (file_directory.rs) transfer almost verbatim to a username-indexed /.htpasswdbackend.HttpDirectory(http_directory.rs) +CachingDirectory(cache.rs) — the "delegate verification to a service" backend and the positive/negative verify cache.The
KeyDirectorytrait needs a deliberate extension, not naive reuse. Todaylookup(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 averify(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: thetoken.delegatehook family anddelegator/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, behindKeyDirectory): Basic should keep the sameprovider-selects-a-backend shape, not thedelegator/*hook family.Net-new:
URL_SAFE_NO_PAD).base64 = "0.23"is already a workspace dep, just not enabled for a Basic feature yet (crates/builtins/Cargo.toml).bcrypt/argon2/ an apr1 reader)..htpasswdparser backend (none exists in the tree today).TokenSource::Basicvariant (crates/ppe-core/src/identity/payload.rs).WWW-Authenticatechallenge emission on denial (today the string appears only in a test fixture, emittingBearer).Proposed config shape
htpasswd backend, minimal case: no
record_mapneeded —subject.idbecomes 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— noAuthorizationheaderauth.empty_credential— header present, nothing after the schemeauth.malformed_credential— base64 fails / no:/ control chars / bad UTF-8auth.user_unknown— user-id not in the directory (see enumeration note)auth.bad_password— user-id found, password verify failedauth.key_expired— record pastexpires_atauth.directory_unavailable— backend could not answer (fail-closed)auth.mapping_failed— record could not be projectedSecurity requirements (carried from #108, plus Basic-specific)
Zeroizing, never written toraw_credentials(so it can't be forwarded upstream), neverDebug/Display/Serialized, never logged — the invariantPresentedKeyalready enforces.{SHA}read-only and gated behind an opt-in; DES-crypt/plaintext unsupported).expires_atwhere the record carries it (file/htpasswd have no native expiry; the YAML backend can add one).Acceptance criteria
kind: identity/basicregisters behind a newbasicCargo feature and viainstall_builtins(facade re-export +register_builtin_plugins!row).Authorization: Basic …, splits on the first colon, enforces no-colon-in-user-id, rejects control chars and bad UTF-8;charset: UTF-8applies NFC.1*SP(reusing/extending the api-keyCredentialLocationlogic; lifted to the sharedCredentialfrom feat!: typed credential locations for JWT identity plugin #96)..htpasswdbackend reading bcrypt ($2*$) and argon2; apr1/{SHA}read-only behind an opt-in; DES-crypt/plaintext rejected at load.expires_at.HttpDirectory/CachingDirectory).refresh_secsrevocation window +max_staleness_secsfail-closed ceiling, atomic whole-index swap (asFileDirectory).ClaimMapper;subject.iddefaults to the user-id; verifier/index fields never reach the projected claims.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.Zeroizing, never inraw_credentials, never logged.TokenSource::Basicadded; denial codes shared with the other identity plugins where the condition is the same.Open questions
KeyDirectoryextension vs new trait. Addverify(user_id, password)toKeyDirectory(keeps one directory abstraction, backend owns the compare) or introduce a Basic-specificCredentialDirectory? Recommendation: extendKeyDirectoryso the file/http backends stay shared, with the password compare living in the backend. Confirm.WWW-Authenticateon the denial? Does it ride PPE's existing customizable-denial-response path, or does the identity hook need a dedicated "challenge" output onIdentityPayload/PluginViolation? This is the main cross-cutting dependency.prefixleader), Basic has no sub-scheme to disambiguate populations, so stacking twoidentity/basicinstances means "try each directory by username". Allowed? Ordered? Or one-per-route with multi-realm left out of v1?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.{SHA}by default (htpasswd compatibility) or requireallow_weak_schemes: true? Writing is out of scope (PPE verifies, it doesn't mint), so this is purely a read-time gate.Secretbackend. 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?