feat: enrollment extra claims, selective /certify claims, and pre-hashed password provisioning - #78
feat: enrollment extra claims, selective /certify claims, and pre-hashed password provisioning#78pointche-ens wants to merge 17 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR extends the authentication server’s credential enrollment and certificate issuance capabilities by adding extra claims support for username/password sessions, selective claim copy into /certify certificates (with optional sub omission), and pre-hashed (PHC) password provisioning for migration scenarios. It also hardens error handling for credential provisioning conflicts and introduces password zeroization in middleware and credential endpoints.
Changes:
- Add
UserPass.extra_claimsand propagate them into session JWTs (username/password only) and selectively into/certifycertificates. - Add
UserPass.hashed_passwordsupport (mutually exclusive with plaintextpassword) with PHC validation, plus improved conflict handling (409 vs idempotent 200). - Update DB schemas/migrations for
extra_claims, refresh OpenAPI/docs/client models, and enhance Admin UI credential creation UX.
Reviewed changes
Copilot reviewed 28 out of 29 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| server/src/tests/username_password_tests.rs | Updates test fixtures/struct literals for new UserPass fields. |
| server/src/tests/helpers.rs | Updates helper create_userpass to populate new UserPass fields. |
| server/src/session/jwt.rs | Extends issue_token to accept and embed extra claims into session JWTs. |
| server/src/server/endpoints/realms_endpoints.rs | Adds hashed_password provisioning, conflict/idempotency logic, and handles extra_claims persistence. |
| server/src/server/endpoints/client_endpoints.rs | Merges extra_claims into login-issued session JWTs and adds /certify selective claim copy + exclude_sub. |
| server/src/server/dev_seed.rs | Seeds dev credentials with new UserPass fields. |
| server/src/middleware/username_password.rs | Zeroizes Basic Auth credential material (Zeroizing<String> for password). |
| server/src/database/trait.rs | Updates internal credential creation to include new UserPass fields. |
| server/src/database/tests.rs | Updates DB tests’ UserPass construction for new fields. |
| server/src/database/passwords.rs | Adds PHC-structure validation helper for pre-hashed password input. |
| server/src/database/mod.rs | Re-exports the new PHC validation helper. |
| server/src/database/impls/sqlite.rs | Adds extra_claims column + migration, and persists/loads extra claims JSON. |
| server/src/database/impls/postgres.rs | Adds extra_claims column + migration, and persists/loads extra claims JSON. |
| server/src/database/impls/mysql.rs | Adds extra_claims column + migration, and persists/loads extra claims JSON. |
| server/src/database/error.rs | Introduces a typed DB Conflict error and maps unique violations cleanly to HTTP 409. |
| server/documentation/openapi.yaml | Updates API contract for hashed_password, extra_claims, and /certify request/claims shape. |
| server/documentation/docs/client_library.md | Documents new enrollment options and extra-claims usage in the client library guide. |
| server/documentation/docs/api_reference.md | Updates REST reference docs for /certify and userpass create/update semantics. |
| server/Cargo.toml | Adds zeroize dependency for the server crate. |
| client/src/models/certificate_claims.rs | Makes certificate sub optional and adds flattened extra claims. |
| client/src/models/base.rs | Extends UserPass model with hashed_password and extra_claims. |
| client/src/error/auth_error.rs | Adds Conflict error variant and maps it to HTTP 409. |
| Cargo.toml | Adds workspace dependency on zeroize. |
| Cargo.lock | Locks zeroize dependency. |
| admin-ui/tests/unit/components/credentials/CredentialModal.test.tsx | Adds unit coverage for hashed-password mode and extra-claims editor behavior. |
| admin-ui/src/types/api.ts | Extends UI API types with hashed_password and extra_claims. |
| admin-ui/src/components/credentials/PasswordFields.tsx | Adds plaintext vs pre-hashed password toggle UI. |
| admin-ui/src/components/credentials/ExtraClaimsEditor.tsx | Adds dynamic key/value editor for extra claims in credential creation. |
| admin-ui/src/components/credentials/CredentialModal.tsx | Wires new password mode + extra-claims creation payload into the credential modal. |
Suppressed comments (1)
server/src/server/endpoints/realms_endpoints.rs:117
create_userpassstill returnsuserpassat the end of the handler withpasswordcontaining the stored PHC bytes (hash or pre-hashed). This unnecessarily exposes password hashes to the API caller; the response should matchget_userpassby returningpassword: [].
// Auto-link: when creating a credential in the admin realm, if an admin
// exists with a matching id, set its `userpass` field to enable login.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Manuthor
left a comment
There was a problem hiding this comment.
🔒 Threat model + standards review — STRIDE-A & RFC conformance
Automated review of 3c45ee4..fe1ac70 (10 commits, 31 files). Analysis grounded in a local read of the IETF RFC corpus; every finding below was reproduced empirically rather than inferred.
Verdict: 🔴 changes requested — one MUST-level standards violation with a working privilege-escalation path, plus a pre-authentication denial-of-service.
First, credit where it's due — the design shows real security reasoning and several non-obvious pitfalls are already closed correctly:
- Fail-closed claim sourcing by auth scheme —
extra_claims/rolesare read only forAuthScheme::UsernamePassword, so a JWT- or mTLS-authenticated identity can't borrow a same-named userpass row's claims. The rationale in the comment is exactly right. - Password hash no longer echoed —
userpass.password = Vec::new()before both the 201 and 200 responses. - 409 instead of a leaked 500, via
is_unique_violation()rather than backend error-string parsing. /certifycopies nothing by default — opt-in, and theexclude_subguard correctly requires a non-empty resolved intersection.extra_claimscan't be silently dropped on the metadata-only update path — you spotted thatupdate_userpass_metadatahas no column for it and rejected the request rather than reporting a success that doesn't apply.- Pre-hashed conflicts always treated as genuine conflicts, correctly declining to compare salted hashes.
The findings all sit in one blind spot: what happens to a claim name once it leaves Rust's type system.
Findings
| ID | Severity | Title | CVSS 4.0 |
|---|---|---|---|
| TM-01 | 🔴 HIGH | extra_claims emits duplicate JWT Claim Names → role/expiry/realm shadowing |
8.1 |
| TM-02 | 🟠 HIGH | Same shadowing in /certify, on long-lived certificates |
7.1 |
| TM-03 | 🟠 HIGH | hashed_password accepts any Argon2 variant/cost → 4 GiB, 4.75 s per login |
7.1 |
| TM-04 | 🟡 MEDIUM | Idempotent-retry path is an unthrottled password oracle | 5.1 |
Four inline suggestions follow, one per finding.
The core issue in one paragraph
ClientClaims and CertificateClaims both end with #[serde(flatten)] pub extra: HashMap<String, Value>, and nothing validates the keys. A realm admin setting extra_claims = {"roles": ["SuperAdmin"]} makes serde_json emit the name roles twice, attacker value last:
{"sub":"victim","exp":1,"roles":["Auditor"],"sub":"attacker","roles":["SuperAdmin"],"exp":9999999999}
RFC 7519 §4 requires Claim Names to be unique, and permits a parser to resolve duplicates by taking the lexically last one. This deployment lands on both sides of that fork simultaneously: the auth server's own serde validator rejects the token — which is why CI is green and the bug is invisible in-repo — while Go, Python, JavaScript, Jackson and OPA relying parties (including the KMS consuming these tokens) read the attacker's roles, exp, and as_rid. as_rid is the realm identifier, so this crosses the service's primary tenant-isolation boundary. In /certify the same defect is written into a certificate designed to outlive its session, and is therefore not revocable by session teardown.
Standards conformance
| Standard | Result |
|---|---|
| RFC 7519 §4 "JWT Claims" | 🔴 MUST violation — claim-name uniqueness |
| RFC 8259 §4 "Objects" | 🟠 SHOULD deviation — unique member names |
| RFC 9106 §4 "Parameter Choice" | 🟠 2 deviations — variant + parameter policy (one pre-existing: Argon2::default() is below both RECOMMENDED options) |
| RFC 9110 §15.5.10 "409 Conflict" | ✅ Conformant |
| RFC 7617 (Basic auth) | ✅ Conformant — first-colon split preserved through the Zeroizing change |
All section headings verified by local read; RFC 9068's §2.2.3.1 citation in client_claims.rs is pre-existing and unverified — worth a manual check before it goes into a compliance package.
Lower-severity items (no inline suggestion)
- 🔵 TM-05 —
extra_claimsmigration:.unwrap_or(false)turns a probe failure into a doomedALTER TABLEwith a misleading error; check-then-act has no advisory lock, so concurrent replicas race; the Postgres predicate lacks thetable_schemafilter its MySQL counterpart has.ADD COLUMN IF NOT EXISTS+?propagation fixes all three. - 🔵 TM-06 —
extra_claimsis unbounded (TEXT, no size or key-count cap) and embedded in every session JWT; an oversized map exceeds common reverse-proxy header limits (nginx default 8 KiB) and breaks the session. Stored cleartext and returned bylist_userpass_by_realm— worth documenting. - ⚪ TM-07 — commit
c8a6c79("zeroize plaintext passwords wherever they transit in memory") overstates coverage: the base64-encoded header slice and the deserializedJson<UserPass>body remain un-zeroized, andUserPassderives neitherZeroizenorZeroizeOnDrop. TheZeroizingadditions themselves are correct — including the observation thatString::from_utf8reuses theVec<u8>allocation, so one wrap does cover both buffers. - Non-security:
CHANGELOG/feature_certify-extra-claims.mddocuments only the admin-UI changes and omits all three server-side features.
Hypotheses tested and refuted (not raised as findings)
- Non-Argon2 PHC strings (pbkdf2/scrypt) accepted by
validate_argon2_phc_string→ refuted,PasswordHash::newrejects them. - SQL injection in the new
extra_claimsqueries → refuted, all three backends use parameterised binds. extra_claimsleaking across auth schemes → refuted, explicit fail-closed guard.exclude_subpermitting an anonymous empty certificate → refuted, the guard checks the resolved intersection.
🤖 Generated with GitHub Copilot CLI — findings are advisory; no code was modified.
…with higher security (as recommended)
… using it as a password oracle
0acd4dc to
3b4a58a
Compare
Summary
UserPass.extra_claims— realm admins can attach arbitrary key/value claims at enrollment, merged into the session JWT on username/password login (fail-closed for other auth schemes, same guard asroles).POST /certifygainsclaims(copy named extra claims from the session into the certificate) andexclude_sub(omit the subject, only when at least one other claim is present).UserPass.hashed_password— an alternative topasswordforcreate/update: accepts a pre-computed Argon2 PHC string and stores it as-is, for migrating credentials already hashed elsewhere. Mutually exclusive withpassword.(realm, username)viaPOST /realms/{realm_id}/userpassused to leak a raw500with the database engine's internal error text. Now returns a clean409 Conflict, except when the resubmitted data is byte-for-byte identical — treated as an idempotent no-op (200) for retry-safe clients.create/update_userpass), viazeroize::Zeroizing.CredentialModalgains a plaintext/pre-hashed password toggle and a key/value extra-claims editor (create mode only — edit mode intentionally stays roles-only, see note below).api_reference.md/client_library.mdupdated to match.Notes / known limitations
update_userpass_metadata(the path taken when onlyroles/change_passwordchange, no password) does not acceptextra_claims— editing extra claims without also touching the password isn't wired up yet. Documented in code.409→idempotent-no-op check only applies to the plaintextpasswordpath; a resubmission viahashed_passwordalways conflicts, sinceget_userpassdeliberately never re-exposes a stored hash for comparison.