Security Review — OID

ODS Identity Service · OAuth 2.0 / OIDC · Rust / Actix-web
Reviewed: 2026-03-18 Commit: 06088df (dev) Reviewer: Security Agent Framework: OWASP Top 10
⛔ STATUS: CRITICAL — PR MUST NOT MERGE
The OID identity service implements OAuth 2.0/OIDC correctly with strong cryptography (RS256 JWT, Argon2 passwords, PKCE-mandatory authorization code flow) and parameterized SQL queries throughout. However, a CRITICAL access-control gap exists: 9 management endpoints (tenants, users, roles, OAuth clients) have NO authentication whatsoever. Any anonymous caller can create tenants, register admin users, assign arbitrary permissions, and register OAuth clients — completely bypassing the identity system. Additionally, internal error messages are leaked to HTTP clients, a login timing side-channel enables email enumeration, and the CORS middleware is declared as a dependency but never configured.
1
Critical
0
High
7
Medium
4
Low
11
Pass
1
Unknown

🔴 Action Items (Priority Order)

1
[CRITICAL] Add AuthenticatedUser extractor + RBAC role checks to all 9 management endpoints
src/main.rs + src/api/{tenants,users,roles,clients}.rs
2
[MEDIUM] Sanitize internal error responses — return generic message, log full error server-side
src/api/{users,tenants,roles,clients,auth,oauth}.rs
3
[MEDIUM] Add dummy password hash verification for non-existent users (timing attack mitigation)
src/service/auth.rs:168
4
[MEDIUM] Configure JSON body size limit (4KB) and explicit CORS policy in App setup
src/main.rs:133
5
[MEDIUM] Install cargo-audit and add to CI pipeline for dependency advisory scanning
Cargo.toml / .github/workflows/
6
[MEDIUM] Replace SHA-256 refresh token hashing with Argon2 or HMAC-SHA256 with server secret
src/domain/refresh_token.rs:29

OWASP Top 10 Findings

A01 Injection PASS
PASS All SQL queries use parameterized binding
Every repository uses sqlx parameterized queries ($1, $2, …). No string interpolation in SQL. Reviewed: tenant_repo.rs, user_repo.rs, role_repo.rs, client_repo.rs, auth_code_repo.rs, refresh_token_repo.rs.
A02 Broken Authentication WARN
PASS JWT validated with RS256 signature — issuer, audience, exp enforced
TokenValidator uses jsonwebtoken with RS256, validates iss and aud claims. Expired token tests pass.
src/domain/token.rs:167
PASS Passwords hashed with Argon2
User passwords hashed with argon2 crate. Verification uses argon2::verify_encoded for constant-time comparison at the hash level.
MEDIUM Timing side-channel on login: password hash skipped for non-existent users
In AuthService::login, find_by_email_and_tenant returns NotFound early — before verify_password is called. Argon2 hashing takes ~100-300ms; requests for non-existent users return in <1ms. An attacker can enumerate valid email/tenant combinations via response timing.
src/service/auth.rs:168
💡 Always call verify_password against a static dummy_hash constant when the user is not found, discarding the result.
MEDIUM Refresh token hashed with plain SHA-256 (unsalted, no stretch)
hash_token() delegates to compute_s256_challenge() — a raw SHA-256 with no salt or work factor. The comment reads "not password, so no argon2 needed". However, if the token_hash column is leaked via SQL injection or DB breach, SHA-256 hashes of 64-char tokens can be reversed offline with GPU acceleration in hours.
src/domain/refresh_token.rs:29
💡 Use Argon2 or HMAC-SHA256 with a server-side secret key (SECRET_KEY env var) for refresh token hashing.
A03 Sensitive Data Exposure WARN
PASS No hardcoded secrets — .gitignore covers all key/cert files
.gitignore excludes: .env, *.pem, *.key, *.crt, *.p12, *.pfx. Secrets scan found only test fixture strings in #[cfg(test)] blocks.
MEDIUM Internal error messages leaked to HTTP clients
Multiple handlers return e.to_string() as error_description in HTTP 500 responses. OidError::Internal wraps raw strings that may include PostgreSQL error text, connection string fragments, or internal paths. Examples: users.rs:107, tenants.rs:84, auth.rs:74.
src/api/users.rs:107, src/api/tenants.rs:84, src/api/auth.rs:74
💡 Return only a generic "An internal error occurred" message for server errors. Log the full error with tracing::error! and a correlation ID server-side.
LOW Email address in JWT payload (plaintext, not encrypted)
JWT Claims includes email field. JWT payload is base64-encoded, not encrypted — readable by anyone with the token. Acceptable per OIDC spec but worth noting for data sensitivity.
src/domain/token.rs:21
A04 XML External Entities (XXE) N/A
INFO JSON-only API — no XML parsing
No XML parsing anywhere in the service. All request/response bodies use serde_json.
A05 Broken Access Control FAIL ⛔
CRITICAL 9 management endpoints require NO authentication
The following routes are registered in main.rs with no AuthenticatedUser extractor and no auth middleware: POST /v1/tenants — anyone can create a tenant GET /v1/tenants/{id} — anyone can read tenant data POST /v1/users — anyone can create users (incl. admin) GET /v1/users/{id} — anyone can read any user profile PATCH /v1/users/{id} — anyone can modify any user PUT /v1/users/{id}/roles — anyone can grant admin roles to any user POST /v1/roles — anyone can create roles with any permissions GET /v1/roles — anyone can enumerate all roles POST /v1/clients — anyone can register OAuth clients GET /v1/clients — anyone can list OAuth clients The AuthenticatedUser extractor EXISTS (src/api/extractors.rs) and works correctly, but is NOT used in any of these handlers. Attack chain: create tenant → create admin user → assign full permissions → register OAuth client → full system takeover.
src/main.rs:149-164 + src/api/{tenants,users,roles,clients}.rs
💡 Add AuthenticatedUser as a parameter to all management handlers. Enforce role checks (admin-only). Tenant creation may need a bootstrap mechanism using service-level client_credentials.
PASS RLS policies on all tenant-scoped tables
Row-Level Security enabled on tenants, users, user_roles, roles, clients, auth_codes, refresh_tokens. All policies gate on app.tenant_id context variable.
PASS PKCE mandatory on authorization code flow
code_challenge required; only S256 accepted. Plain method explicitly rejected. Single-use codes atomically consumed via UPDATE … WHERE used = false.
src/domain/auth_code.rs:77
PASS Redirect URI validated — HTTPS enforced, fragments rejected
redirect_uri validated against registered list. HTTPS required (localhost exempt). Fragment (#) in redirect_uri rejected to prevent token leakage.
A06 Security Misconfiguration WARN
MEDIUM actix-cors declared as dependency but never configured
actix-cors 0.7 is in Cargo.toml but no Cors middleware is added to App::new() in main.rs. CORS policy is undefined — browsers apply same-origin by default, but this should be explicit.
src/main.rs:133 / Cargo.toml:9
💡 Configure Cors::default().allowed_origin(…) explicitly, or remove actix-cors if not needed for this service.
MEDIUM No JSON request body size limit configured
No web::JsonConfig with .limit() registered. Default actix-web limit is 32KB. Login/token endpoints should be capped at 4KB to reduce memory pressure under flood conditions.
src/main.rs:133
💡 Add .app_data(web::JsonConfig::default().limit(4096)) to App configuration.
LOW RSA key pair regenerated on every startup — sessions invalidated on restart
KeyPair::generate() runs on each boot (main.rs:67). All issued JWTs become invalid after any restart or deployment. No graceful key rotation. JWKS endpoint serves only the current ephemeral key.
src/main.rs:67
💡 Store RSA private key in Vault or a secrets manager. On startup, load existing key if present; generate only on first boot. Serve old keys in JWKS for their remaining TTL during rotation.
PASS No debug mode in production code paths
RUST_LOG defaults to "info". No cfg(debug_assertions) gated production code paths. TracingLogger used for structured logging.
A07 Cross-Site Scripting (XSS) N/A
INFO API-only service — no HTML rendered
All responses are JSON. No server-side HTML template rendering. XSS not applicable.
A08 Insecure Deserialization PASS
PASS Standard serde_json deserialization — no unsafe patterns
All request bodies deserialized with #[derive(Deserialize)] via serde_json. No unsafe transmute, no custom Deserialize with side effects, no untyped serde_json::Value used for security decisions.
A09 Known Vulnerabilities UNKNOWN
MEDIUM cargo-audit not installed — dependency advisory scan could not run
cargo audit returned "no such command". RustSec advisory database scan could not be performed. Dependencies include: actix-web 4, sqlx 0.8, jsonwebtoken 9, rsa 0.9, argon2 0.5, rdkafka 0.36, chrono 0.4, rand 0.9.
Cargo.toml
💡 Run: cargo install cargo-audit && cargo audit. Add to CI: cargo audit --deny warnings.
A10 Insufficient Logging and Monitoring WARN
PASS Authentication events emitted (login.succeeded, login.failed)
login.succeeded and login.failed CloudEvents emitted to Redpanda on every auth attempt. TracingLogger middleware on all requests.
src/api/auth.rs:44
MEDIUM Internal error details leaked in HTTP response body
Catch-all error arms return e.to_string() as error_description. For OidError::Internal, this exposes database error messages, internal paths, and other implementation details to clients. This is both an A03 and A10 concern.
src/api/auth.rs:74
💡 Log full error with tracing::error!(error=?e, ...). Return only generic "server_error" externally.
LOW No correlation ID propagation in logs
TracingLogger::default() is used but X-Correlation-Id header is not extracted or generated. Cross-service request tracing is not possible without correlation IDs.
src/main.rs:135
💡 Extract X-Correlation-Id header or generate a UUID per request and include in all log spans via tracing::Span.

Additional Checks

Unsafe Code

✅ PASS
grep for unsafe in src/**/*.rs returned zero matches. No unsafe blocks in any production code.

Secrets Scan

✅ PASS
No hardcoded credentials found. One test fixture string ("test-token-value") in #[cfg(test)] block — not a real secret. .gitignore covers .env, *.pem, *.key, *.crt.

Dependency Review

✅ PASS (with note)
All dependencies are well-maintained crates from reputable sources (RustCrypto, Actix, sqlx). One unused dependency: actix-cors (declared, never used in App setup)

Cargo Audit

⚠ UNKNOWN
cargo-audit not installed. Advisory scan could not run.

cargo install cargo-audit
cargo audit

Input Validation

✅ PASS
Email validated (@ + domain). Password: 8-128 chars, upper+lower+digit required. Slug: 3-63 chars, alphanumeric+hyphens. Redirect URIs: HTTPS required, fragments rejected. PKCE verifier: RFC 7636 compliant.

SQL Injection

✅ PASS
All queries use sqlx parameterized binding (.bind() calls). Zero raw SQL string interpolation found. No raw queries with user-controlled values.