Skip to content

feat: enrollment extra claims, selective /certify claims, and pre-hashed password provisioning - #78

Open
pointche-ens wants to merge 17 commits into
developfrom
feature/certify-extra-claims
Open

feat: enrollment extra claims, selective /certify claims, and pre-hashed password provisioning#78
pointche-ens wants to merge 17 commits into
developfrom
feature/certify-extra-claims

Conversation

@pointche-ens

Copy link
Copy Markdown
Contributor

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 as roles).
  • POST /certify gains claims (copy named extra claims from the session into the certificate) and exclude_sub (omit the subject, only when at least one other claim is present).
  • UserPass.hashed_password — an alternative to password for create/update: accepts a pre-computed Argon2 PHC string and stores it as-is, for migrating credentials already hashed elsewhere. Mutually exclusive with password.
  • Fix: re-provisioning an existing (realm, username) via POST /realms/{realm_id}/userpass used to leak a raw 500 with the database engine's internal error text. Now returns a clean 409 Conflict, except when the resubmitted data is byte-for-byte identical — treated as an idempotent no-op (200) for retry-safe clients.
  • Security hardening: plaintext passwords are now zeroized on drop wherever they transit in memory (Basic Auth extraction, create/update_userpass), via zeroize::Zeroizing.
  • Admin UI: CredentialModal gains 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).
  • OpenAPI + api_reference.md/client_library.md updated to match.

Notes / known limitations

  • update_userpass_metadata (the path taken when only roles/change_password change, no password) does not accept extra_claims — editing extra claims without also touching the password isn't wired up yet. Documented in code.
  • The 409→idempotent-no-op check only applies to the plaintext password path; a resubmission via hashed_password always conflicts, since get_userpass deliberately never re-exposes a stored hash for comparison.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_claims and propagate them into session JWTs (username/password only) and selectively into /certify certificates.
  • Add UserPass.hashed_password support (mutually exclusive with plaintext password) 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_userpass still returns userpass at the end of the handler with password containing the stored PHC bytes (hash or pre-hashed). This unnecessarily exposes password hashes to the API caller; the response should match get_userpass by returning password: [].
    // 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.

Comment thread server/src/server/endpoints/realms_endpoints.rs Outdated
Comment thread server/src/server/endpoints/realms_endpoints.rs
Comment thread server/src/database/passwords.rs
Comment thread server/src/server/endpoints/realms_endpoints.rs Outdated
Comment thread server/src/server/endpoints/client_endpoints.rs

@Manuthor Manuthor left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 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 schemeextra_claims/roles are read only for AuthScheme::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 echoeduserpass.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.
  • /certify copies nothing by default — opt-in, and the exclude_sub guard correctly requires a non-empty resolved intersection.
  • extra_claims can't be silently dropped on the metadata-only update path — you spotted that update_userpass_metadata has 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-05extra_claims migration: .unwrap_or(false) turns a probe failure into a doomed ALTER TABLE with a misleading error; check-then-act has no advisory lock, so concurrent replicas race; the Postgres predicate lacks the table_schema filter its MySQL counterpart has. ADD COLUMN IF NOT EXISTS + ? propagation fixes all three.
  • 🔵 TM-06extra_claims is 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 by list_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 deserialized Json<UserPass> body remain un-zeroized, and UserPass derives neither Zeroize nor ZeroizeOnDrop. The Zeroizing additions themselves are correct — including the observation that String::from_utf8 reuses the Vec<u8> allocation, so one wrap does cover both buffers.
  • Non-security: CHANGELOG/feature_certify-extra-claims.md documents 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_stringrefuted, PasswordHash::new rejects them.
  • SQL injection in the new extra_claims queries → refuted, all three backends use parameterised binds.
  • extra_claims leaking across auth schemes → refuted, explicit fail-closed guard.
  • exclude_sub permitting an anonymous empty certificate → refuted, the guard checks the resolved intersection.

🤖 Generated with GitHub Copilot CLI — findings are advisory; no code was modified.

Comment thread server/src/server/endpoints/realms_endpoints.rs
Comment thread server/src/server/endpoints/client_endpoints.rs
Comment thread server/src/database/passwords.rs Outdated
Comment thread server/src/server/endpoints/realms_endpoints.rs Outdated
Comment thread client/src/models/base.rs Outdated
Comment thread client/src/models/base.rs
@pointche-ens
pointche-ens force-pushed the feature/certify-extra-claims branch from 0acd4dc to 3b4a58a Compare September 2, 2026 21:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants