Conformance
This file maps every MUST and SHOULD that Zeabur Identity implements to the code that implements it and
the test that proves it. Normative words follow RFC 2119 and RFC 8174. The design reasons and the
full citations are in each crate's documentation (cargo doc --workspace --open).
Status values: Met (code and test), Partial (explained in the row), Not implemented, N/A (with the reason).
Code paths are relative to crates/. Tests are given as crate: module::tests::name for unit
tests and crate/tests/file.rs: name for integration tests. Tests marked (cluster) run against
k3s v1.36.5 (--run-ignored only); (e2e) is zeabur-identity-e2e/tests/e2e.rs, which runs Zeabur Identity from
its container image.
Summary by specification
Section titled “Summary by specification”| Specification | Version / status (2026-10) | Status |
|---|---|---|
| OpenID Connect Discovery 1.0 | Final (errata set 2) | Met |
| RFC 8414 OAuth 2.0 Authorization Server Metadata | RFC | Met |
| RFC 7515 JWS, RFC 7517 JWK, RFC 7518 JWA, RFC 7638 thumbprints | RFC | Met |
| RFC 7519 JWT, RFC 8725 JWT BCP, RFC 9068 JWT access tokens | RFC | Met |
KEP-3331 Structured Authentication Configuration (apiserver.config.k8s.io/v1) | GA in Kubernetes 1.34 | Met on k3s; RKE2 configuration generation implemented, clean release installation unverified |
| RFC 6749 OAuth 2.0 (authorization code, refresh) | RFC | Met |
| RFC 7636 PKCE | RFC | Met (S256 only) |
| RFC 9700 OAuth 2.0 Security BCP | BCP 240 | Partial: sender-constrained tokens (DPoP) not implemented |
| RFC 6750 Bearer token usage | RFC | Met for UserInfo |
| RFC 8707 Resource Indicators | RFC | Met (one resource per request) |
| RFC 7009 Token Revocation | RFC | Met (refresh tokens; access tokens answered with unsupported_token_type) |
| RFC 9126 PAR | RFC | Met |
| RFC 9207 Authorization server issuer identification | RFC | Met |
| OpenID Connect Core 1.0 | Final (errata set 2) | Partial: no single sign-on sessions, so every authorization request asks for a sign-in |
| W3C WebAuthn Level 3 | Recommendation | Met for registration, discoverable sign-in and conditional mediation |
WebAuthn Signal API (signalAllAcceptedCredentials, signalUnknownCredential) | WebAuthn Level 3 | Met where the browser has it (feature-detected) |
| W3C A Well-Known URL for Relying Party Passkey Endpoints | Working Draft | Met |
| NIST SP 800-63B-4 | Final, Jul. 2025 | Partial: user-verified passkeys (AAL2-capable); with the admin-aal3 setting, admin accounts need a device-bound passkey (BE unset, self-reported: attestation is none); refresh sessions end after 1 h idle (15 min for admins with several teams or AAL3) and 12 h after sign-in; account-page sessions end after one hour and need a fresh verified passkey within five minutes to add or remove one, or to link or unlink an upstream account |
| Cookies (draft-ietf-httpbis-rfc6265bis) | Draft | Met for the __Host- cookies used |
| RFC 9457 Problem Details | RFC | Met for the sign-in endpoints |
| RFC 8176 Authentication Method Reference values | RFC | Met (swk, mfa for passkeys) |
| WCAG 2.2 AA | W3C Recommendation | Partial: contrast computed and met for every colour pair used; no automated browser audit or screen-reader pass yet |
| OpenID Connect RP-Initiated Logout 1.0 | Final | Not implemented (no sessions) |
| OpenID Connect Back-Channel Logout 1.0 | Final | Not implemented |
| RFC 9449 DPoP | RFC | Not implemented: kube-apiserver accepts only bearer tokens |
| RFC 9396 Rich Authorization Requests | RFC | N/A: kube-apiserver cannot evaluate authorization_details; not advertised |
| RFC 9470 Step-up Authentication Challenge | RFC | N/A: kube-apiserver never sends the challenge. Admin rights come from a separate, passkey-only admin account instead |
| OAuth 2.1 (draft-ietf-oauth-v2-1) | Draft | Followed where it tightens OAuth 2.0 (PKCE always, S256 only) |
OpenID Foundation conformance suite, oidcc-basic-certification-test-plan | — | Not run: the suite's browser automation cannot perform a passkey ceremony, and Zeabur Identity has no password login to give it |
Repository requirements
Section titled “Repository requirements”| Requirement | Code | Check | Status |
|---|---|---|---|
unsafe_code = "forbid" | Cargo.toml [workspace.lints.rust]; #![forbid(unsafe_code)] in both binaries | CI clippy, unsafe-report | Met |
| Only OSI-approved licences | deny.toml [licenses] | CI cargo-deny | Met |
| Known vulnerabilities rejected | deny.toml [advisories] | CI cargo-deny | Met |
Dependency unsafe/native code listed for approval | scripts/unsafe-report.sh, supply-chain/unsafe-allowlist.toml | CI unsafe-report | Met |
| clippy pedantic + restriction set, rustfmt | Cargo.toml [workspace.lints], clippy.toml, rustfmt.toml | CI clippy, format, docs | Met |
| DCO sign-off | CONTRIBUTING.md, scripts/check-dco.sh | CI dco | Met |
| One pinned Rust toolchain | flake.nix, flake.lock | CI clippy, format in nix develop | Met |
| Image: static binary, non-root, no shell | Containerfile, zeabur-identity-k8s/src/deployment.rs | zeabur-identity-k8s: deployment::tests::deployment_is_locked_down; (e2e) | Met |
| Image size and idle memory measured in CI | scripts/build-image.sh, e2e measurement | CI kubernetes job summary | Met (about 20 MB; pod working set within the 30 MiB budget) |
| Image signing (Sigstore) and SBOM | .github/workflows/release.yml, scripts/release-{build,publish,verify}.sh | Exact-digest signature/SBOM verification and public pull in both native release rehearsal jobs | Partial: pipeline implemented; public OIDC publication evidence requires a successful tag-triggered run (release procedure) |
| Versioned CLI archives, checksums, signed provenance and SBOMs | Same workflow; scripts/release-smoke.sh | Signed checksum manifest; downloaded archive execution and Kubernetes-backed container startup on Linux amd64/arm64 while release stays draft; separate maintainer publication after installation acceptance | Partial: pipeline implemented; local smoke is not evidence of a published release; #43 remains unverified |
OpenID Connect Discovery 1.0 and RFC 8414
Section titled “OpenID Connect Discovery 1.0 and RFC 8414”| § | Requirement | Level | Code | Test | Status |
|---|---|---|---|---|---|
| Disc. 3 | issuer: https, no query or fragment | REQUIRED | zeabur-identity-core/src/config.rs BaseUrl::parse | zeabur-identity-core: config::tests::rejects_everything_else | Met |
| Disc. 3 | authorization_endpoint, token_endpoint, userinfo_endpoint, jwks_uri (https) | REQUIRED | zeabur-identity-oauth/src/discovery.rs | zeabur-identity-oauth: discovery::tests::required_members_are_present | Met |
| Disc. 3 | id_token_signing_alg_values_supported includes RS256 | MUST | discovery.rs, zeabur-identity-keys Algorithm::ALL | same | Met |
| Disc. 3 | scopes_supported includes openid | MUST | discovery.rs | same | Met |
| Disc. 3 | Defaults that would over-claim set explicitly | — | discovery.rs | zeabur-identity-oauth: discovery::tests::does_not_inherit_misleading_defaults | Met |
| Disc. 4, 4.3 | Document at /.well-known/openid-configuration; issuer identical | MUST | zeabur-identity-oauth/src/metadata.rs | zeabur-identity-oauth: metadata::tests::both_metadata_locations_serve_the_same_document | Met |
| 8414 §3, §3.3 | Well-known location; identical issuer; RFC 9068 §4 consistency | MUST | one document at both paths | same | Met |
| Disc. 7.1 | TLS for discovery | MUST | TLS at the ingress; the internal listener for kube-apiserver uses a private CA that cert-manager keeps | zeabur-identity: internal::tests::serves_metadata_with_the_issued_certificate_and_renews_in_place | Met |
JOSE: RFC 7515, 7517, 7518, 7638, 8725
Section titled “JOSE: RFC 7515, 7517, 7518, 7638, 8725”| § | Requirement | Level | Code | Test | Status |
|---|---|---|---|---|---|
| 7515 §4.1.1, §4.1.4 | alg present; kid identifies the key | MUST | zeabur-identity-keys/src/jws.rs | zeabur-identity-keys: jws::tests::independent_library_verifies_with_published_jwk | Met |
| 7515 §4.1.11 | Unknown critical extensions rejected | MUST | zeabur-identity-keys/src/verify.rs | zeabur-identity-keys: verify::tests::rejects_tampering_wrong_keys_and_algorithms | Met |
| 7517 §4.5, §4.3, §5 | Distinct kids; not use with key_ops; set format | SHOULD | zeabur-identity-keys/src/jwk.rs | zeabur-identity-keys: jwk::tests::*, rotation::tests::schedule_invariants | Met |
| 7518 §3.3, §3.4, §3.6 | RSA ≥ 2048 bits (3072 generated); fixed-width ES256; no none | MUST | zeabur-identity-keys/src/signing.rs, verify.rs | zeabur-identity-keys: signing::tests::*, verify::tests::* | Met |
| 7638 §3 | Thumbprint canonical form | MUST | jwk.rs | zeabur-identity-keys: jwk::tests::rfc7638_example_thumbprint | Met |
| 8725 §3.1 | One algorithm per key | MUST | signing.rs, verify.rs | zeabur-identity-keys: signing::tests::rejects_key_for_the_wrong_algorithm, verify::tests::an_rsa_key_never_verifies_an_es256_header | Met |
| 8725 §3.10, §3.11 | No jku/x5u/jwk headers; explicit typ | — / RECOMMENDED | jws.rs (at+jwt, JWT) | zeabur-identity-oauth/tests/oauth.rs: code_flow_issues_tokens_kubernetes_can_use | Met |
| OIDC Core §10.1.1 | Keep decommissioned keys published for a while | SHOULD | zeabur-identity-keys/src/rotation.rs | zeabur-identity-keys: rotation::tests::* | Met |
kube-apiserver integration (KEP-3331)
Section titled “kube-apiserver integration (KEP-3331)”| Source | Requirement | Code | Test | Status |
|---|---|---|---|---|
oidc.go | RS/ES/PS algorithms only | ES256 default, RS256 | — | Met |
| go-oidc v2 key cache | A new key is visible before it signs | zeabur-identity-keys/src/rotation.rs publish-ahead; max-age below it | zeabur-identity-keys: rotation::tests::schedule_invariants, zeabur-identity: cli::tests::rejects_bad_issuers_and_inconsistent_timings | Met |
validation.go | discoveryURL https and different from url | internal TLS listener at the Service's ClusterIP | (e2e) | Met |
oidc.go | Internal discovery keeps issuer; jwks_uri from that document | zeabur-identity-oauth/src/discovery.rs | zeabur-identity-oauth: discovery::tests::internal_variant_changes_only_the_jwks_uri; (e2e) | Met |
| AuthenticationConfiguration | Prefixed usernames and groups; no system:; access tokens only (has(claims.client_id)); lifetime bound | zeabur-identity-k8s/src/kube_config.rs | zeabur-identity-k8s: kube_config::tests::*; (e2e) passkey_sign_in_then_kubectl_auth_whoami | Met |
| Distributions | anonymous per distribution; install steps for K3s/RKE2 | kube_config.rs | zeabur-identity-k8s: kube_config::tests::* | Partial: configuration generation tested; cluster E2E covers k3s only, RKE2 release installation unverified |
| Project requirement | kubectl auth whoami on k3s | — | (e2e) | Met |
Kubernetes storage
Section titled “Kubernetes storage”| Source | Requirement | Code | Test | Status |
|---|---|---|---|---|
| API concepts | Update with resourceVersion is compare-and-swap; delete preconditions; create of an existing name fails | zeabur-identity-k8s/src/objects.rs | zeabur-identity-k8s/tests/kubernetes.rs: exactly_one_racer_wins_each_primitive (cluster) | Met |
| CRD validation | CEL within the cost budget | zeabur-identity-k8s/src/crd/*.rs | admission_rejects_invalid_objects (cluster) | Met |
| RBAC | Least privilege: Secrets only by name, no create on Secrets, no RBAC verbs; create on core Events and update/patch on credentials/status for the account page | zeabur-identity-k8s/src/rbac.rs | service_account_has_least_privilege (cluster), zeabur-identity-k8s: rbac::tests::* | Met |
| RFC 9700 §2.1; RFC 8252 §7.3; RFC 6749 §3.1.2 | Redirect URIs are https:// with a host, or a loopback http:// with a path; printable ASCII, no fragment or user information. Admission checks this with CEL isURL()/url(); zeabur-identityctl client create additionally requires a valid WHATWG URL written in its serialized form with well-formed percent-encoding, so exact matching sees the string a browser uses and admission accepts it | zeabur-identity-k8s/src/crd/client.rs REDIRECT_URI_RULE, zeabur-identity-k8s/src/clients.rs redirect_uri_allowed | zeabur-identity-k8s: clients::tests::redirect_uris_are_https_or_loopback_in_the_form_a_browser_uses, clients::tests::invalid_registrations_are_rejected_with_the_offending_value; tests/redirect_uris.rs (property: accepted URIs survive the WHATWG and RFC 3986 parsers and the authorization response, loopback ports match, the browser's form is accepted); admission_rejects_invalid_objects, admission_and_zeabur-identityctl_agree_on_generated_redirect_uris (cluster: generated candidates; what zeabur-identityctl accepts is admitted, and what admission lets in is never a non-https, non-loopback, fragment or credential target to a browser) | Met |
| RFC 6749 §10.10 | Generated client secrets: 256 bits from the CSPRNG (guessing probability at most 2^-128 is a MUST, at most 2^-160 a SHOULD); only the hash is stored | zeabur-identity-k8s/src/clients.rs register | zeabur-identity-k8s: clients::tests::confidential_clients_get_a_fresh_random_secret_and_only_its_hash, clients::tests::emitted_objects_satisfy_the_schema_rules; oauth_flow_on_kubernetes_storage (cluster: the printed secret authenticates, a wrong one is invalid_client) | Met |
| Project requirement | Expired objects are cleaned up | zeabur-identity-k8s/src/gc.rs | garbage_collector_deletes_expired_objects_only (cluster) | Met |
| Project requirement | Signing keys in a Secret, rotation with overlap | zeabur-identity-k8s/src/keys.rs | signing_keys_persist_and_only_one_writer_wins (cluster) | Met |
| WebAuthn Level 3 §7.1 | A known credential ID fails registration | zeabur-identity-store passkey_credential_name, create-once | zeabur-identity-signin/tests/signin.rs; setup_link_is_single_use_and_replaceable (cluster) | Met |
| RFC 9700 §4.14.2 | Refresh rotation with reuse detection | zeabur-identity-k8s/src/grants.rs, zeabur-identity-store classify | grant_rotation_is_linearizable (cluster: 1 of 64 rotates) | Met |
| Project account-recovery policy; RFC 9700 §4.14.2 (security-event revocation, MAY) | Reset deletes pending invitations, then credentials, then refresh-token families before writing a setup link, including zero-credential retries after partial failure | zeabur-identity-k8s/src/setup.rs | Account-recovery tests | Met |
Account-recovery tests
Section titled “Account-recovery tests”zeabur-identity-k8s/tests/setup_reset.rs:reset_revokes_invitations_before_anything_else,reset_retries_grant_deletion_after_credentials_are_goneandreset_revokes_old_grants_without_credentialsexercise partial-failure recovery using a mock Kubernetes HTTP API and the real OAuth router.zeabur-identity-k8s/tests/kubernetes.rs:setup_link_is_single_use_and_replaceablechecks on a cluster that reset revokes only the target user's invitation.zeabur-identity-e2e/tests/e2e.rs:passkey_sign_in_then_kubectl_auth_whoamichecks on a cluster that refresh succeeds before reset and fails afterward.
OAuth 2.0 and OpenID Connect
Section titled “OAuth 2.0 and OpenID Connect”Tests are in zeabur-identity-oauth/tests/oauth.rs unless noted.
| Spec § | Requirement | Level | Code (zeabur-identity-oauth/src/) | Test | Status |
|---|---|---|---|---|---|
| 6749 §3.1, §3.2 | Parameters not repeated | MUST | params.rs | authorization_endpoint_errors | Met |
| 6749 §3.1.2.3; 9700 §2.1 | Exact redirect URI match (loopback port excepted) | MUST | authorize.rs | zeabur-identity-oauth: authorize::tests::redirect_uri_rules | Met |
| 6749 §4.1.2.1 | No redirect for an invalid client or redirect URI | MUST NOT | endpoints.rs (HTML error page) | authorization_endpoint_errors; zeabur-identity-signin/tests/pages.rs: untrusted_authorization_requests_show_an_error_page | Met |
| 6749 §4.1.2 | Codes short-lived and single use; reuse revokes issued tokens | MUST | code.rs, endpoints.rs | code_is_single_use_and_reuse_revokes_what_it_issued, exactly_one_concurrent_redemption_succeeds | Met |
| 6749 §4.1.3 | Code bound to client and redirect_uri | MUST | endpoints.rs | pkce_and_code_binding_are_enforced | Met |
| 6749 §2.3.1, §5.2 | Client authentication; WWW-Authenticate for Basic | MUST | client_auth.rs | confidential_client_gets_rs256_id_tokens_and_must_authenticate | Met |
| 6749 §5.1 | Cache-Control: no-store | MUST | error.rs no_store | code_flow_issues_tokens_kubernetes_can_use | Met |
| 6749 §10.10 | Token guessing ≤ 2^-128 | MUST | 256-bit secrets; sealed codes | — | Met |
| 6749 §10.12 | CSRF protection for the authorization endpoint | MUST | approval is a same-origin JSON sign-in bound to the browser's sealed flow cookie | zeabur-identity-signin/tests/signin.rs: requests_from_other_sites_are_refused, sign_in_needs_a_pending_authorization_request | Met |
| 7636 §4.1, §4.4.1, §4.6 | Verifier format; challenge required; S256 compare | MUST | pkce.rs, authorize.rs | zeabur-identity-oauth: pkce::tests::*, authorize::tests::* | Met |
| 9700 §2.1.1, §2.2.2 | PKCE required; public-client refresh tokens rotated | MUST | authorize.rs, endpoints.rs | pkce_and_code_binding_are_enforced, refresh_rotates_and_reuse_revokes_the_family | Met |
| 9700 §4.11.2 | 303, never 307 | MUST NOT | endpoints.rs | code_flow_issues_tokens_kubernetes_can_use | Met |
| 9068 §2.1, §2.2, §5 | at+jwt; required claims; distinct aud per resource | MUST | tokens.rs, authorize.rs | code_flow_issues_tokens_kubernetes_can_use, authorization_endpoint_errors | Met |
| 8707 §2 | invalid_target for disallowed resources | — | authorize.rs | zeabur-identity-oauth: authorize::tests::* | Met |
| 9126 §2.1, §2.2, §4 | Authenticated PAR; one-time, client-bound, expiring request URI | MUST | endpoints.rs | pushed_authorization_requests | Met |
| 7009 §2.1, §2.2 | Revocation by the owning client with constant-time proof of a current or previous refresh secret; 200 for invalid tokens without deleting a grant | MUST | endpoints.rs, refresh.rs | revocation, guessed_refresh_secrets_cannot_revoke_a_known_grant, current_and_previous_refresh_secrets_authorize_family_revocation; zeabur-identity-e2e: passkey_sign_in_then_kubectl_auth_whoami (forged-secret smoke) | Met |
| 6749 §6 | Validate refresh token and client binding; wrong-client family deletion requires constant-time proof of a current or previous secret, never a grant name alone | MUST; deletion policy | endpoints.rs, refresh.rs | guessed_refresh_secrets_cannot_revoke_a_known_grant, current_and_previous_refresh_secrets_authorize_family_revocation; zeabur-identity-e2e: passkey_sign_in_then_kubectl_auth_whoami (second public-client forged-secret smoke) | Met |
| Kubernetes Object Names and IDs | A refresh token renews only for the User whose UID the grant recorded; a grant without a recorded UID belongs to no one, and none renews for a new User with the same name | Project requirement | endpoints.rs refresh | a_session_left_from_a_removed_user_does_not_refresh_as_a_new_user_of_that_name | Met |
| Kubernetes Object Names and IDs | An authorization code is redeemed only for the User whose UID it sealed at sign-in, never for a new User with the same name; no grant is created | Project requirement | code.rs CodeClaims, endpoints.rs exchange_code | a_code_for_a_removed_user_is_not_redeemed_for_a_new_user_of_that_name | Met |
| 9207 §2 | iss in authorization responses | MUST | authorize.rs | code_flow_issues_tokens_kubernetes_can_use | Met |
| OIDC Core §2, §3.1.3.6 | ID token claims; aud is the client; azp | REQUIRED | tokens.rs | code_flow_issues_tokens_kubernetes_can_use | Met |
| OIDC Core §3.1.2.1 | GET and POST; prompt=none without a session fails | MUST | endpoints.rs, authorize.rs | authorization_endpoint_accepts_post, authorization_endpoint_errors | Met |
| OIDC Core §3.1.2.1 | ui_locales | OPTIONAL | error page | zeabur-identity-signin/tests/pages.rs: untrusted_authorization_requests_show_an_error_page | Partial: the sign-in page does not use it |
| OIDC Core §5.3; RFC 6750 §2.1, §3 | UserInfo with bearer access tokens; challenges with error codes; current claims by scope | MUST | userinfo.rs | userinfo_returns_current_claims_for_access_tokens_only | Met |
| OIDC Core §5.3.2 | Always return the grant's sub; resolve metadata by UID and verify the directly read user's UID and enabled state | MUST; binding policy | userinfo.rs, zeabur-identity-k8s/src/directory.rs | userinfo_returns_current_claims_for_access_tokens_only, userinfo_rejects_deleted_and_recreated_users, userinfo_rejects_unknown_and_ambiguous_subjects | Met (client-side ID token subject comparison remains the client's responsibility) |
| OIDC Core §12.2 | Refreshed ID token keeps auth_time, no nonce | MUST | endpoints.rs | refresh_rotates_and_reuse_revokes_the_family | Met |
| OIDC Core §15.1 | max_age, prompt=login | MUST | every sign-in is a fresh ceremony (no sessions) | zeabur-identity-signin/tests/signin.rs: setup_link_then_passkey_sign_in_issues_tokens; auth_time_is_the_sign_in_and_survives_refresh (a request with max_age shows the sign-in page) | Met while there are no sessions |
Account types and sessions
Section titled “Account types and sessions”OAuth tests are in zeabur-identity-oauth/tests/oauth.rs unless noted.
| Requirement | Code | Test | Status |
|---|---|---|---|
Admin accounts sign in only with a passkey; with admin-aal3, only with a device-bound passkey (BE unset); regular accounts are unaffected | zeabur-identity-core/src/account.rs allows | zeabur-identity-core: account::tests::admin_accounts_sign_in_with_passkeys_only, account::tests::aal3_refuses_synced_passkeys_for_admins_only | Met |
| The token endpoint applies the same policy at code exchange and at every refresh, so a session that no longer qualifies ends at its next refresh | zeabur-identity-oauth/src/endpoints.rs | admin_accounts_need_a_passkey_and_with_aal3_a_device_bound_one | Met |
| Admin accounts neither sign in with nor link GitHub or Google | zeabur-identity-signin/src/upstream.rs, lib.rs | zeabur-identity-signin/tests/upstream.rs: admin_accounts_neither_sign_in_with_nor_link_github_or_google | Met |
With admin-aal3, a synced passkey can neither sign in to nor be enrolled for an admin account | zeabur-identity-signin/src/lib.rs, account.rs | zeabur-identity-signin/tests/signin.rs: with_aal3_an_admin_cannot_sign_in_with_a_synced_passkey, with_aal3_an_admin_enrols_only_a_device_bound_passkey | Met |
Only admin accounts' tokens carry the admins group, and their tokens are shorter (5 min vs 10 min); the CRD refuses admins in spec.groups | zeabur-identity-oauth/src/tokens.rs, zeabur-identity-k8s/src/crd/user.rs | only_admin_accounts_carry_the_admin_group_and_their_tokens_are_shorter | Met |
Refresh idle: 1 h, or 15 min for admins with teams: multiple or admin-aal3; every family ends 12 h after sign-in; refresh_expires_in in the token response | zeabur-identity-oauth/src/lib.rs Lifetimes::for_account, endpoints.rs | zeabur-identity-oauth: tests::lifetimes_per_account_type, refresh_idle_follows_the_account_type_and_the_settings; zeabur-identity-core: account::tests::short_admin_idle_follows_teams_or_aal3 | Met |
auth_time is the passkey or upstream sign-in and survives refresh; the 12 h limit counts from it | endpoints.rs | auth_time_is_the_sign_in_and_survives_refresh | Met |
Settings come from the ConfigMap zeabur-identity-settings, read at every sign-in and refresh; missing means default, a value not understood means the stricter setting | zeabur-identity-core/src/account.rs Settings::from_data, zeabur-identity-k8s/src/directory.rs | zeabur-identity-core: account::tests::settings_from_a_config_map | Met |
First admin: setup-link creates an admin account; zeabur:admins is bound to cluster-admin, so the admin has full rights without a second sign-in; a refresh needs no passkey and follows the account type (a regular account loses the rights) | zeabur-identity-k8s/src/rbac.rs, setup.rs | (e2e) passkey_sign_in_then_kubectl_auth_whoami | Met |
Sign-in: passkeys, enrolment links, invitations
Section titled “Sign-in: passkeys, enrolment links, invitations”Tests are in zeabur-identity-signin/tests/signin.rs unless noted.
| Source | Requirement | Level | Code (zeabur-identity-signin/src/) | Test | Status |
|---|---|---|---|---|---|
| Project requirement | Setup link works once and expires; no first-visitor admin | MUST | lib.rs (consume before storing), zeabur-identity-k8s/src/setup.rs | setup_token_is_single_use, concurrent_enrolments_with_one_token_add_one_passkey, setup_token_expires_and_must_match, setup_link_is_single_use_and_replaceable (cluster, 64 concurrent) | Met |
| Project requirement | Invitations work once, only for the invited user; the service account cannot create them | MUST | lib.rs, zeabur-identity-k8s/src/setup.rs, invitations.rs | invitation_enrols_the_invited_user_once, invitations_are_single_use_and_admin_only (cluster) | Met |
| WebAuthn §5.4.4, §5.4.6 | Discoverable credential, user verification required | — | passkeys.rs | zeabur-identity-signin: passkeys::tests::registration_requires_resident_keys_and_uv | Met |
| WebAuthn §7.1, §7.2 | UV verified; user handle is the owner's; challenge single use | MUST | passkeys.rs, lib.rs | passkeys_without_user_verification_are_refused (registration, client side), sign_in_without_user_verification_is_refused (signed assertion with UV clear, refused with no code or tokens; UV-positive control completes), unknown_passkeys_and_disabled_users_cannot_sign_in, assertions_cannot_be_replayed | Met |
| WebAuthn conditional mediation | Autofill (autocomplete="username webauthn") | — | passkeys.rs, ui/src/login.ts | zeabur-identity-signin/tests/pages.rs: login_offers_passkey_autofill_for_a_pending_request | Met |
| RFC 9457 §3 | Problem details | — | problem.rs | requests_from_other_sites_are_refused | Met |
| Fetch Metadata | Cross-site state-changing requests refused | — | lib.rs check_request | requests_from_other_sites_are_refused, zeabur-identity-signin: tests::same_origin_checks | Met |
| RFC 6265bis | __Host- cookies: Secure, Path=/, no Domain | MUST | lib.rs, upstream.rs | code_flow_issues_tokens_kubernetes_can_use | Met |
Account page
Section titled “Account page”/account, where a user manages their own passkeys and sessions. Tests are in
zeabur-identity-signin/tests/signin.rs (tests::account::*) unless noted.
| Source | Requirement | Level | Code | Test | Status |
|---|---|---|---|---|---|
| Project requirement | Every read and write is filtered to the session's user by name and UID; names in requests only select among those | MUST | zeabur-identity-signin/src/account.rs, zeabur-identity-k8s/src/credentials.rs owned_passkeys, grants.rs list | the_page_lists_only_the_session_users_passkeys, changes_cannot_reach_another_users_objects; zeabur-identity-k8s/tests/account.rs: only_the_users_own_passkeys_are_listed_or_changed, grants_are_listed_by_uid_and_reauthenticated_by_secret | Met |
| Project requirement; NIST SP 800-63B-4 §2.2.3 | Adding or removing a passkey needs a user-verified assertion from the last 5 minutes, by one of the user's own passkeys, recorded on the session | MUST | account.rs is_fresh, reauth | adding_and_removing_need_a_recent_verified_passkey, changes_cannot_reach_another_users_objects | Met |
| Project requirement | The last passkey is never removed, including by concurrent removals | MUST | zeabur-identity-k8s/src/credentials.rs remove (revision bump on the others, then delete with preconditions) | the_last_passkey_cannot_be_removed, concurrent_removals_keep_one_passkey; zeabur-identity-k8s/tests/account.rs: interleaved_removals_keep_a_passkey, sequential_removals_stop_at_the_last_passkey | Met |
| Project requirement | Removing a passkey revokes every session and grant that signed in or re-authenticated with it | MUST | account.rs revoke_by_credential | removing_a_passkey_revokes_what_it_signed_in | Met |
| OWASP CSRF Prevention | Changes need the session cookie, same-origin Fetch Metadata and a per-session token | MUST | account.rs signed_in, SessionToken::csrf | rename_needs_the_csrf_token_and_same_origin | Met |
| RFC 6265bis | Session cookie __Host-, HttpOnly, SameSite=Lax; only the secret's hash is stored; one hour, not extended; ends when the user is disabled or recreated | MUST | account.rs | sessions_end_after_an_hour_and_with_the_user, sessions_can_be_revoked | Met |
| WebAuthn §5.4.3 | excludeCredentials lists the user's passkeys; resident key and UV required | SHOULD | account.rs add_options, passkeys.rs | adding_and_removing_need_a_recent_verified_passkey | Met |
| WebAuthn Signal API | signalAllAcceptedCredentials only with a complete, non-empty list; signalUnknownCredential for an unregistered credential | — | lib.rs accepted, Refused; ui/src/webauthn.ts signal | removing_a_passkey_revokes_what_it_signed_in | Met |
| Passkey Endpoints well-known URL | /.well-known/passkey-endpoints returns enroll and manage, 200 JSON, no redirect | — | account.rs passkey_endpoints | passkey_endpoints_are_published | Met |
| Project requirement | Add, rename, remove and session revocation leave a zeabur_identity::audit log line and an Event on the User (actor, credential, AAGUID, client IP, user agent) | MUST | account.rs log_outcome, record; zeabur-identity-k8s/src/credentials.rs record | adding_and_removing_need_a_recent_verified_passkey, rename_needs_the_csrf_token_and_same_origin; zeabur-identity-k8s/tests/account.rs: only_the_users_own_passkeys_are_listed_or_changed | Met |
| Project requirement | A passkey's label is the one the user typed; without one, the account page names it by its creation date | — | lib.rs new_passkey; zeabur-identity-ui/templates/account.html | a_passkey_without_a_label_shows_its_creation_date | Met |
Upstream sign-in (GitHub, Google)
Section titled “Upstream sign-in (GitHub, Google)”Tests are in zeabur-identity-signin/tests/upstream.rs.
| Source | Requirement | Code (zeabur-identity-signin/src/) | Test | Status |
|---|---|---|---|---|
| RFC 6749 §10.12; RFC 9700 §2.1 | state bound to the browser, compared in constant time, used once | upstream.rs | unlinked_disabled_and_replayed_sign_ins_fail | Met |
| RFC 7636 | PKCE towards the provider | upstream/provider.rs | a_linked_github_account_signs_in_and_gets_a_code (fake provider checks the verifier) | Met |
| OIDC Core §3.1.3.7 | ID token: signature from the provider's JWK set, iss, aud exactly the client ID (no other audience trusted), azp if present equal to it, exp, iat, nonce | upstream/provider.rs | google_id_tokens_are_validated | Met |
| Project requirement | Only linked accounts sign in; linking only through an enrolment link, once, or from a signed-in account page | upstream.rs | an_invitation_links_an_account_once, unlinked_disabled_and_replayed_sign_ins_fail, a_signed_in_user_links_an_account_on_the_account_page | Met |
| RFC 6749 §10.12 | A link from the account page starts only with a fresh verified passkey and the CSRF token; its sealed state names the session, user and UID, and the callback links nothing unless that session is still signed in for that user | upstream.rs account_start, Bound | linking_needs_a_fresh_confirmation_and_the_session_that_started_it | Met |
| Project requirement | An upstream account already linked to any user is not linked again; admin accounts cannot link, checked at the start and at the callback | upstream.rs finish_account_link | an_account_linked_to_anyone_is_not_linked_again, admin_accounts_cannot_link_from_the_account_page | Met |
| Project requirement | Unlinking needs a fresh verified passkey, never removes the user's last credential (also under concurrent unlinks), revokes what the account signed in, and records AccountUnlinked | account.rs unlink; zeabur-identity-k8s/src/credentials.rs unlink | unlinking_keeps_a_way_to_sign_in_and_ends_what_the_account_signed_in; zeabur-identity-k8s/tests/account.rs: the_last_credential_is_never_unlinked, interleaved_unlinks_keep_a_credential, only_linked_accounts_the_user_owns_are_listed_or_unlinked | Met |
| Kubernetes Object Names and IDs | A linked account signs in only as the User whose UID its owner reference names, never as a new User with the same name | upstream.rs callback; zeabur-identity-k8s/src/credentials.rs linked_account, link | a_link_left_from_a_removed_user_does_not_sign_in_as_a_new_user_of_that_name; zeabur-identity-k8s/tests/account.rs: a_linked_account_signs_in_only_as_the_user_that_owns_it | Met |
| Kubernetes Object Names and IDs | An invitation or setup link enrols a passkey or links an account only for the User whose UID its owner reference names | lib.rs valid_setup_token; zeabur-identity-k8s/src/setup.rs load_setup, load_invitation | signin.rs: a_link_left_from_a_removed_user_does_not_enrol_a_new_user_of_that_name; zeabur-identity-k8s/tests/account.rs: an_invitation_enrols_only_the_user_that_owns_it | Met |
Sign-in pages
Section titled “Sign-in pages”Tests are in zeabur-identity-signin/tests/pages.rs; every page test checks headers and that the markup
needs nothing the CSP forbids.
| Source | Requirement | Level | Code | Test | Status |
|---|---|---|---|---|---|
| RFC 9700 §4.16 | Prevent clickjacking (CSP frame-ancestors, X-Frame-Options) | MUST | zeabur-identity-ui/src/lib.rs harden | all page tests | Met |
| CSP Level 3, Trusted Types | No inline script, style or handlers; no string-to-HTML sinks | — | zeabur-identity-ui, ui/src/*.ts | all page tests | Met |
| RFC 9110 §12.5.4, §12.4.2 | Accept-Language with quality values | — | zeabur-identity-ui/src/i18n.rs | zeabur-identity-ui: i18n::tests::*, language_follows_parameter_then_header | Met |
| Project requirement | Assets embedded, no CDN; built output checked against source; size measured | MUST | zeabur-identity-ui/src/assets.rs, scripts/build-ui.sh, scripts/ui-budget.sh | assets_are_embedded_versioned_and_immutable; CI ui | Met |
| Project requirement | No tokens in browser storage; cookies HttpOnly, Secure, SameSite | MUST | ui/src/*.ts, zeabur-identity-signin | bun run typecheck in ui/, review | Met |
| Project requirement | Fallback without passkey support or script; provider buttons only when configured | MUST | templates | login_offers_passkey_autofill_for_a_pending_request, provider_buttons_appear_only_when_configured | Met |
| Project requirement | Dark mode; en, zh-TW, zh-CN | MUST | ui/styles/app.css, zeabur-identity-ui/src/i18n.rs | language_follows_parameter_then_header | Met |
| Escaping | User-influenced text cannot inject markup | — | askama auto-escaping | user_controlled_text_is_escaped | Met |
| WCAG 2.2 1.4.3, 1.4.11 | Text ≥ 4.5:1; boundaries and focus ≥ 3:1 | AA | the design system's tokens, ui/styles/app.css | contrast.py (CI ui), computed table in the zeabur-identity-ui docs | Met for every pair used |
| WCAG 2.2 2.4.7, 2.5.8, 3.1.1, 3.1.2, 3.3.1, 4.1.3 | Focus, target size, language, errors, status messages | A / AA | templates | language_follows_parameter_then_header; review | Met by inspection |
| WCAG 2.2 | Automated audit and assistive-technology pass | — | — | — | Not done |