diff --git a/articles/APPKey_HOWTO.md b/articles/APPKey_HOWTO.md new file mode 100644 index 000000000..85ec98dac --- /dev/null +++ b/articles/APPKey_HOWTO.md @@ -0,0 +1,459 @@ +# Personal API Keys — Configuration & Usage Guide + +This document describes how personal API keys work in WebAPI, how they are +implemented, and how to use them for programmatic access to WebAPI services. + +--- + +## Overview + +Personal API keys allow authenticated users to generate long-lived credentials +for use by scripts, automated pipelines, and other programmatic clients that +cannot perform an interactive login. + +An API key request is completely stateless — no session is created or +maintained. Once a key is presented on a request, the system resolves it to +the owning user's identity and the request is processed exactly as if that +user had authenticated via their normal login method. + +Key properties of the implementation: + +- **No secrets stored in the database.** Only a BCrypt hash of the key's + secret component is persisted. +- **O(1) database lookup during authentication.** The key format includes a + public identifier stored in a uniquely indexed column; this avoids scanning + every row to find a match before running the expensive BCrypt comparison. +- **Scoped to the creating user.** Keys can only be managed (listed, revoked, + deleted) by the user who created them. +- **Optional expiry.** Keys can be created with a specific lifetime in days, or + with no expiry (never expires). +- **Two-stage deletion.** Keys can be soft-disabled (still visible in the list + but cannot authenticate), or permanently removed from the database. + +--- + +## Key Format + +Every API key has the form: + +``` +wa__ +``` + +Example: + +``` +wa_a1b2c3d4e5f6a7b8_hA7xPWLsl8B0ktTgrzvQcMi1rIh0g7oXsNpQqYvFjR2 +``` + +| Component | Description | +|---|---| +| `wa` | Fixed prefix identifying the token as a WebAPI API key | +| `identifier` | 16-character lowercase hex string (8 random bytes). **Not secret** — used for indexed database lookup. | +| `secret` | 43-character Base64-URL string (32 random bytes, no padding). **Secret** — never stored; only its BCrypt hash is persisted. | + +The `identifier` portion is stored in plain text in an indexed `UNIQUE` column +(`key_identifier`) in the `sec_api_key` table. This allows the authentication +filter to perform a single indexed lookup on each request before the +computationally expensive BCrypt verification step runs. + +--- + +## Authentication Flow + +When a request carries an `X-API-KEY` header, the following sequence occurs: + +``` +Incoming request (X-API-KEY: wa__) + │ + ▼ +ApiKeyAuthFilter (OncePerRequestFilter — runs before JWT/Bearer filter) + │ + ├─ Parse key into identifier + secret + │ + ├─ SELECT * FROM sec_api_key WHERE key_identifier = ? ← indexed, O(1) + │ + ├─ Check: disabled = false + ├─ Check: expires_at IS NULL OR expires_at > NOW() + │ + ├─ BCrypt.matches(secret, key_hash) ← slow by design + │ + ├─ UPDATE sec_api_key SET last_used_at = NOW() + │ + └─ Set WebApiAuthenticationToken(principal, sessionId=null) in SecurityContext + │ + ▼ + Request proceeds normally + (@PreAuthorize, AuthorizationService, etc. work as for any authenticated user) +``` + +If the header is absent the filter is a no-op and normal JWT authentication +proceeds on the same request. + +If the key is present but invalid (wrong secret, disabled, expired, or unknown +identifier) the filter returns `401 Unauthorized` immediately with no further +processing. + +--- + +## Architecture + +### Key classes + +| Class | Package | Responsibility | +|---|---|---| +| `ApiKeyAuthFilter` | `security.apikey` | `OncePerRequestFilter`; intercepts `X-API-KEY` header and authenticates the request | +| `ApiKeyService` | `security.apikey` | Key generation, validation, listing, soft-disable, and hard delete | +| `ApiKeyEntity` | `security.apikey` | JPA entity mapping to `sec_api_key` table | +| `ApiKeyRepository` | `security.apikey` | Spring Data repository; `findByKeyIdentifier()` is the hot path | +| `ApiKeyController` | `security.apikey` | REST endpoints for key management (`/user/apikeys`) | +| `JwtAuthConfig` | `security.authc` | Wires `ApiKeyAuthFilter` before `AuthenticationFilter` in the main security chain | + +### Database table: `sec_api_key` + +| Column | Type | Notes | +|---|---|---| +| `id` | `BIGINT` PK | Internal identifier (not exposed via API) | +| `key_identifier` | `VARCHAR(64)` UNIQUE | Public lookup key; indexed | +| `key_hash` | `VARCHAR(255)` | BCrypt hash of the secret component | +| `user_id` | `BIGINT` FK | References `sec_user(id) ON DELETE CASCADE` | +| `name` | `VARCHAR(255)` | User-provided label | +| `description` | `VARCHAR(1000)` | Optional free-text description | +| `created_at` | `TIMESTAMPTZ` | Creation timestamp | +| `expires_at` | `TIMESTAMPTZ` | Expiry timestamp; `NULL` means never expires | +| `disabled` | `BOOLEAN` | `true` after a soft-revoke | +| `last_used_at` | `TIMESTAMPTZ` | Updated on every successful authentication | + +--- + +## REST Endpoints + +All endpoints require the caller to be authenticated (interactive JWT session +or an existing API key). The caller can only see and manage their own keys. + +### `POST /user/apikeys` — Create a key + +**Request body (JSON):** + +```json +{ + "name": "my-pipeline-key", + "description": "Used by the nightly ETL job", + "expiresInDays": 180 +} +``` + +| Field | Type | Required | Description | +|---|---|---|---| +| `name` | string | Yes | Short human-readable label | +| `description` | string | No | Optional longer description | +| `expiresInDays` | integer | No | Days until the key expires. `null` or `0` means it never expires. | + +**Response (`201 Created`):** + +```json +{ + "name": "my-pipeline-key", + "keyIdentifier": "a1b2c3d4e5f6a7b8", + "rawKey": "wa_a1b2c3d4e5f6a7b8_hA7xPWLsl8B0ktTgrzvQcMi1rIh0g7oXsNpQqYvFjR2", + "createdAt": "2026-06-24T14:30:00Z", + "expiresAt": "2026-12-21T14:30:00Z" +} +``` + +> **Important:** `rawKey` is returned exactly once. Store it securely +> immediately. It cannot be retrieved again — only the BCrypt hash is kept in +> the database. + +--- + +### `GET /user/apikeys` — List keys + +Returns metadata for all keys owned by the authenticated user. Secrets and +hashes are never included. + +**Response (`200 OK`):** + +```json +[ + { + "name": "my-pipeline-key", + "description": "Used by the nightly ETL job", + "keyIdentifier": "a1b2c3d4e5f6a7b8", + "createdAt": "2026-06-24T14:30:00Z", + "expiresAt": "2026-12-21T14:30:00Z", + "disabled": false, + "lastUsedAt": "2026-06-24T18:45:00Z" + } +] +``` + +--- + +### `DELETE /user/apikeys/{keyIdentifier}` — Revoke or remove a key + +The `keyIdentifier` path segment is the 16-character hex identifier from the +creation response (the part between the first and second `_` in the full key). + +| Request | Effect | +|---|---| +| `DELETE /user/apikeys/{keyIdentifier}` | **Soft-disable.** Sets `disabled = true`. The key record remains visible in the list but can no longer authenticate. | +| `DELETE /user/apikeys/{keyIdentifier}?remove` | **Hard delete.** Permanently removes the row from the database. | + +**Response:** `204 No Content` on success, `404 Not Found` if the key does not +exist or does not belong to the authenticated user. + +--- + +## Using an API Key + +Include the raw key in the `X-API-KEY` request header on any WebAPI request: + +``` +X-API-KEY: wa_a1b2c3d4e5f6a7b8_hA7xPWLsl8B0ktTgrzvQcMi1rIh0g7oXsNpQqYvFjR2 +``` + +The `Authorization: Bearer` header is **not** used for API key authentication. +If both headers are present, the API key takes precedence (the filter runs +first and short-circuits JWT processing for that request). + +--- + +## Example Scripts (bash) + +The examples below assume: +- WebAPI is running at `http://localhost:8080` +- The user has first obtained a JWT via their normal login method (DB login + shown here; substitute `windows`, `openid`, or LDAP as appropriate) + +### Step 0: Login and capture a JWT + +```bash +# Login with DB credentials and extract the JWT +TOKEN=$(curl -s -u alice:password1 http://localhost:8080/user/login/db \ + | sed -n 's/.*"jwt"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') + +echo "JWT: $TOKEN" +``` + +--- + +### Create an API key (expires in 180 days) + +```bash +RESPONSE=$(curl -s -X POST http://localhost:8080/user/apikeys \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "my-pipeline-key", + "description": "Nightly ETL job", + "expiresInDays": 180 + }') + +echo "$RESPONSE" + +# Extract the raw key for use in subsequent requests +API_KEY=$(echo "$RESPONSE" | sed -n 's/.*"rawKey"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') +KEY_IDENTIFIER=$(echo "$RESPONSE" | sed -n 's/.*"keyIdentifier"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') + +echo "API Key: $API_KEY" +echo "Identifier: $KEY_IDENTIFIER" +``` + +> **Save `$API_KEY` now.** This is the only time it will be returned. + +--- + +### Create a non-expiring API key + +```bash +curl -s -X POST http://localhost:8080/user/apikeys \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "permanent-key", + "description": "No expiry" + }' +``` + +--- + +### Use an API key to call a WebAPI endpoint + +```bash +# Call GET /user/me using only the API key — no JWT needed +curl -s \ + -H "X-API-KEY: $API_KEY" \ + http://localhost:8080/user/me +``` + +Expected response contains the `user` block for the key's owner and their +`authz` (permissions) block, identical to an interactive session for that user. + +```bash +# Call any other protected endpoint the same way +curl -s \ + -H "X-API-KEY: $API_KEY" \ + http://localhost:8080/cohortdefinition +``` + +--- + +### List all API keys for the current user + +```bash +# Using JWT session +curl -s \ + -H "Authorization: Bearer $TOKEN" \ + http://localhost:8080/user/apikeys + +# Or using an existing API key (keys can manage themselves) +curl -s \ + -H "X-API-KEY: $API_KEY" \ + http://localhost:8080/user/apikeys +``` + +--- + +### Soft-revoke a key (disable, keep record) + +The key can no longer authenticate after this, but it remains visible in +`GET /user/apikeys` with `"disabled": true`. + +```bash +curl -s -X DELETE \ + -H "Authorization: Bearer $TOKEN" \ + http://localhost:8080/user/apikeys/$KEY_IDENTIFIER \ + -w "\nHTTP_CODE:%{http_code}\n" +# Expected: HTTP_CODE:204 + +# Confirm the key is now disabled +curl -s \ + -H "Authorization: Bearer $TOKEN" \ + http://localhost:8080/user/apikeys +# "disabled": true appears in the record + +# Confirm the key no longer authenticates +curl -s \ + -H "X-API-KEY: $API_KEY" \ + http://localhost:8080/user/me \ + -w "\nHTTP_CODE:%{http_code}\n" +# Expected: HTTP_CODE:401 +``` + +--- + +### Hard-delete a key (permanently remove record) + +Appending `?remove` to the DELETE URL permanently removes the row from the +database. The record will no longer appear in `GET /user/apikeys`. + +```bash +curl -s -X DELETE \ + -H "Authorization: Bearer $TOKEN" \ + "http://localhost:8080/user/apikeys/$KEY_IDENTIFIER?remove" \ + -w "\nHTTP_CODE:%{http_code}\n" +# Expected: HTTP_CODE:204 + +# Confirm the key is gone +curl -s \ + -H "Authorization: Bearer $TOKEN" \ + http://localhost:8080/user/apikeys +# The key no longer appears in the list +``` + +--- + +### Full end-to-end example + +```bash +#!/usr/bin/env bash +# end_to_end_apikey.sh — demonstrates the full API key lifecycle + +WEBAPI="http://localhost:8080" + +echo "=== Step 1: Login and get a JWT ===" +TOKEN=$(curl -s -u alice:password1 "$WEBAPI/user/login/db" \ + | sed -n 's/.*"jwt"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') +[ -z "$TOKEN" ] && { echo "Login failed"; exit 1; } +echo "JWT obtained." + +echo "" +echo "=== Step 2: Create an API key (valid 90 days) ===" +RESPONSE=$(curl -s -X POST "$WEBAPI/user/apikeys" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"name":"e2e-test-key","expiresInDays":90}') +echo "$RESPONSE" + +API_KEY=$(echo "$RESPONSE" | sed -n 's/.*"rawKey"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') +KEY_IDENTIFIER=$(echo "$RESPONSE" | sed -n 's/.*"keyIdentifier"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') +echo "Key identifier: $KEY_IDENTIFIER" + +echo "" +echo "=== Step 3: Call /user/me using the API key (no JWT) ===" +curl -s -H "X-API-KEY: $API_KEY" "$WEBAPI/user/me" + +echo "" +echo "=== Step 4: List keys ===" +curl -s -H "Authorization: Bearer $TOKEN" "$WEBAPI/user/apikeys" + +echo "" +echo "=== Step 5: Soft-revoke the key ===" +curl -s -X DELETE \ + -H "Authorization: Bearer $TOKEN" \ + "$WEBAPI/user/apikeys/$KEY_IDENTIFIER" \ + -w "\nHTTP_CODE:%{http_code}\n" + +echo "" +echo "=== Step 6: Confirm key is disabled (expects 401) ===" +curl -s -H "X-API-KEY: $API_KEY" "$WEBAPI/user/me" \ + -w "\nHTTP_CODE:%{http_code}\n" + +echo "" +echo "=== Step 7: Hard-delete the key ===" +curl -s -X DELETE \ + -H "Authorization: Bearer $TOKEN" \ + "$WEBAPI/user/apikeys/$KEY_IDENTIFIER?remove" \ + -w "\nHTTP_CODE:%{http_code}\n" + +echo "" +echo "=== Done ===" +``` + +--- + +## Security Considerations + +### Secret never stored +The full `rawKey` string is generated in memory, returned once in the creation +response, and immediately discarded. Only `BCrypt(secret)` is written to +the database. There is no recovery path — if a key is lost, revoke it and +create a new one. + +### BCrypt work factor +The service uses BCrypt with a strength factor of 12. This is intentionally +slow to resist offline brute-force attacks against stolen database rows. The +O(1) identifier lookup ensures that the BCrypt step only runs once per request +against a single, already-identified record. + +### Identifier collision safety +The identifier is 8 cryptographically random bytes (64 bits of entropy). The +service retries up to 5 times on a unique-constraint violation before throwing +an error. At any realistic scale, collisions will never occur. + +### Ownership enforcement +Both `revoke()` and `delete()` in `ApiKeyService` verify that the key's owning +user login matches the caller's authenticated login before making any change. +A 404 is returned (rather than 403) when ownership fails, to avoid revealing +whether a given identifier exists in the system. + +### Transport security +API keys must only be transmitted over HTTPS in production. Sending an API key +over plain HTTP exposes it to network interception. + +### Key rotation +There is no automatic rotation. Users are responsible for rotating keys on a +schedule appropriate to their environment. The `expiresInDays` parameter +enforces a hard lifetime; set it to a finite value for any key used in +long-running automated processes. diff --git a/src/main/java/org/ohdsi/webapi/security/apikey/ApiKeyAuthFilter.java b/src/main/java/org/ohdsi/webapi/security/apikey/ApiKeyAuthFilter.java new file mode 100644 index 000000000..fcbdf089b --- /dev/null +++ b/src/main/java/org/ohdsi/webapi/security/apikey/ApiKeyAuthFilter.java @@ -0,0 +1,79 @@ +package org.ohdsi.webapi.security.apikey; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.ohdsi.webapi.security.authc.WebApiAuthenticationToken; +import org.ohdsi.webapi.security.authz.User; +import org.ohdsi.webapi.security.identity.WebApiPrincipal; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +/** + * Servlet filter that authenticates requests carrying an {@code X-API-KEY} header. + * + *

The filter runs before Spring Security's {@code BearerTokenAuthenticationFilter}. + * If the header is absent the request is passed down the chain unchanged, allowing + * normal JWT authentication to proceed. If the header is present but the key is + * invalid, a 401 response is returned immediately. + * + *

Successful API key authentication sets a {@link WebApiAuthenticationToken} with + * a {@code null} session ID in the {@link SecurityContextHolder}, matching the + * stateless intent of API key access. + */ +@Component +public class ApiKeyAuthFilter extends OncePerRequestFilter { + + public static final String API_KEY_HEADER = "X-API-KEY"; + + private static final Logger log = LoggerFactory.getLogger(ApiKeyAuthFilter.class); + + private final ApiKeyService apiKeyService; + + public ApiKeyAuthFilter(ApiKeyService apiKeyService) { + this.apiKeyService = apiKeyService; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + String apiKey = request.getHeader(API_KEY_HEADER); + if (apiKey == null || apiKey.isBlank()) { + // No API key header — let the JWT filter handle the request normally. + filterChain.doFilter(request, response); + return; + } + + Optional userOpt = apiKeyService.validate(apiKey); + if (userOpt.isEmpty()) { + log.debug("Rejected invalid API key from {}", request.getRemoteAddr()); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json"); + response.getWriter().write("{\"message\":\"Invalid or expired API key\"}"); + return; + } + + WebApiPrincipal principal = new WebApiPrincipal(userOpt.get()); + // Authorities are empty, consistent with the existing JWT converter behaviour. + // Per-endpoint @PreAuthorize rules evaluate permissions via the AuthorizationService. + Collection authorities = List.of(); + // sessionId is null: API key requests are stateless and carry no session. + WebApiAuthenticationToken auth = WebApiAuthenticationToken.authenticated(principal, null, authorities); + SecurityContextHolder.getContext().setAuthentication(auth); + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/org/ohdsi/webapi/security/apikey/ApiKeyController.java b/src/main/java/org/ohdsi/webapi/security/apikey/ApiKeyController.java new file mode 100644 index 000000000..2b4a1ecaf --- /dev/null +++ b/src/main/java/org/ohdsi/webapi/security/apikey/ApiKeyController.java @@ -0,0 +1,120 @@ +package org.ohdsi.webapi.security.apikey; + +import org.ohdsi.webapi.security.authz.AuthorizationService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; + +/** + * REST endpoints for managing personal API keys. + * + *

All endpoints require an authenticated principal (interactive session or an + * existing API key). The raw key is returned exactly once, at creation time. + * + *

+ *   POST   /user/apikeys        – generate a new key
+ *   GET    /user/apikeys        – list keys for the current user (no secrets/hashes)
+ *   DELETE /user/apikeys/{id}   – revoke (disable) a key
+ * 
+ */ +@RestController +@RequestMapping("/user/apikeys") +public class ApiKeyController { + + private static final Logger log = LoggerFactory.getLogger(ApiKeyController.class); + + private final ApiKeyService apiKeyService; + private final AuthorizationService authorizationService; + + public ApiKeyController(ApiKeyService apiKeyService, AuthorizationService authorizationService) { + this.apiKeyService = apiKeyService; + this.authorizationService = authorizationService; + } + + /** + * Request body for creating an API key. + * + * @param name short label for the key (required) + * @param description optional longer description + * @param expiresInDays number of days until the key expires; null or 0 means the key never expires + */ + public record CreateRequest(String name, String description, Integer expiresInDays) { + } + + /** + * Generate a new API key for the currently authenticated user. + * + *

The response includes the full plaintext key in {@code rawKey}. + * This is the only time it will ever be returned — store it securely. + */ + @PostMapping + @PreAuthorize("isAuthenticated()") + public ResponseEntity create(@RequestBody CreateRequest request) { + + if (request.name() == null || request.name().isBlank()) { + return ResponseEntity.badRequest().build(); + } + + String login = authorizationService.getCurrentUser().login(); + Instant expiresAt = (request.expiresInDays() != null && request.expiresInDays() > 0) + ? Instant.now().plus(request.expiresInDays(), ChronoUnit.DAYS) + : null; + ApiKeyService.ApiKeyResult result = apiKeyService.generate( + login, request.name(), request.description(), expiresAt); + + log.info("API key created via controller: identifier={}, user={}", result.keyIdentifier(), login); + return ResponseEntity.status(HttpStatus.CREATED).body(result); + } + + /** + * List all API keys for the currently authenticated user. + * Key hashes and secrets are never included in the response. + */ + @GetMapping + @PreAuthorize("isAuthenticated()") + public List list() { + String login = authorizationService.getCurrentUser().login(); + return apiKeyService.list(login); + } + + /** + * Revoke or permanently delete an API key by its public identifier. + *

    + *
  • Without {@code ?remove}: soft-disables the key (visible in list, cannot authenticate).
  • + *
  • With {@code ?remove}: permanently removes the record from the database.
  • + *
+ * Returns 404 if the key does not exist or does not belong to the caller. + */ + @DeleteMapping("/{keyIdentifier}") + @PreAuthorize("isAuthenticated()") + public ResponseEntity revoke( + @PathVariable String keyIdentifier, + @RequestParam(name = "remove", required = false) String remove) { + String login = authorizationService.getCurrentUser().login(); + try { + if (remove != null) { + apiKeyService.delete(keyIdentifier, login); + } else { + apiKeyService.revoke(keyIdentifier, login); + } + return ResponseEntity.noContent().build(); + } catch (IllegalArgumentException e) { + log.debug("Revoke rejected for key identifier={}, user={}: {}", keyIdentifier, login, e.getMessage()); + return ResponseEntity.notFound().build(); + } + } +} diff --git a/src/main/java/org/ohdsi/webapi/security/apikey/ApiKeyEntity.java b/src/main/java/org/ohdsi/webapi/security/apikey/ApiKeyEntity.java new file mode 100644 index 000000000..db5c24e9f --- /dev/null +++ b/src/main/java/org/ohdsi/webapi/security/apikey/ApiKeyEntity.java @@ -0,0 +1,93 @@ +package org.ohdsi.webapi.security.apikey; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.SequenceGenerator; +import jakarta.persistence.Table; + +import org.ohdsi.webapi.security.authz.UserEntity; + +import java.time.Instant; + +@Entity(name = "ApiKey") +@Table(name = "sec_api_key") +public class ApiKeyEntity { + + @Id + @Column(name = "id") + @SequenceGenerator(name = "sec_api_key_seq", sequenceName = "sec_api_key_sequence", allocationSize = 1) + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sec_api_key_seq") + private Long id; + + /** + * Public, indexed portion of the key used for O(1) database lookup. + * This is NOT a secret — it is stored as plain text. + */ + @Column(name = "key_identifier", unique = true, nullable = false, length = 64) + private String keyIdentifier; + + /** + * BCrypt hash of the secret portion of the key. + * The plaintext secret is never stored. + */ + @Column(name = "key_hash", nullable = false, length = 255) + private String keyHash; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private UserEntity user; + + @Column(name = "name", nullable = false, length = 255) + private String name; + + @Column(name = "description", length = 1000) + private String description; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "expires_at") + private Instant expiresAt; + + @Column(name = "disabled", nullable = false) + private boolean disabled = false; + + @Column(name = "last_used_at") + private Instant lastUsedAt; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public String getKeyIdentifier() { return keyIdentifier; } + public void setKeyIdentifier(String keyIdentifier) { this.keyIdentifier = keyIdentifier; } + + public String getKeyHash() { return keyHash; } + public void setKeyHash(String keyHash) { this.keyHash = keyHash; } + + public UserEntity getUser() { return user; } + public void setUser(UserEntity user) { this.user = user; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + + public Instant getCreatedAt() { return createdAt; } + public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } + + public Instant getExpiresAt() { return expiresAt; } + public void setExpiresAt(Instant expiresAt) { this.expiresAt = expiresAt; } + + public boolean isDisabled() { return disabled; } + public void setDisabled(boolean disabled) { this.disabled = disabled; } + + public Instant getLastUsedAt() { return lastUsedAt; } + public void setLastUsedAt(Instant lastUsedAt) { this.lastUsedAt = lastUsedAt; } +} diff --git a/src/main/java/org/ohdsi/webapi/security/apikey/ApiKeyRepository.java b/src/main/java/org/ohdsi/webapi/security/apikey/ApiKeyRepository.java new file mode 100644 index 000000000..db43b008f --- /dev/null +++ b/src/main/java/org/ohdsi/webapi/security/apikey/ApiKeyRepository.java @@ -0,0 +1,20 @@ +package org.ohdsi.webapi.security.apikey; + +import org.ohdsi.webapi.security.authz.UserEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface ApiKeyRepository extends JpaRepository { + + /** + * O(1) lookup by the public identifier portion of the key. + * The identifier column has a UNIQUE index, so this is an indexed point lookup. + */ + Optional findByKeyIdentifier(String keyIdentifier); + + List findByUser(UserEntity user); +} diff --git a/src/main/java/org/ohdsi/webapi/security/apikey/ApiKeyService.java b/src/main/java/org/ohdsi/webapi/security/apikey/ApiKeyService.java new file mode 100644 index 000000000..c1c9a10d1 --- /dev/null +++ b/src/main/java/org/ohdsi/webapi/security/apikey/ApiKeyService.java @@ -0,0 +1,261 @@ +package org.ohdsi.webapi.security.apikey; + +import org.ohdsi.webapi.security.authz.User; +import org.ohdsi.webapi.security.authz.UserEntity; +import org.ohdsi.webapi.security.authz.UserRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.security.SecureRandom; +import java.time.Instant; +import java.util.Base64; +import java.util.HexFormat; +import java.util.List; +import java.util.Optional; + +/** + * Service for creating, validating, listing, and revoking personal API keys. + * + *

Key format: {@code wa__} + *

    + *
  • {@code identifier} — 8 random bytes as lowercase hex (16 chars). Stored in plain text + * in an indexed column for O(1) lookup during authentication.
  • + *
  • {@code secret} — 32 random bytes as Base64-URL without padding (43 chars). Never + * stored; only its BCrypt hash is persisted.
  • + *
+ * + *

Authentication flow: + *

    + *
  1. Parse the key into {@code identifier} and {@code secret}.
  2. + *
  3. Look up the record by {@code identifier} (indexed, single-row read).
  4. + *
  5. Verify {@code secret} against the stored BCrypt hash via {@code matches()}.
  6. + *
+ */ +@Service +public class ApiKeyService { + + private static final Logger log = LoggerFactory.getLogger(ApiKeyService.class); + + private static final String KEY_PREFIX = "wa"; + private static final int MAX_IDENTIFIER_RETRIES = 5; + + private final ApiKeyRepository apiKeyRepository; + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final SecureRandom secureRandom; + + public ApiKeyService(ApiKeyRepository apiKeyRepository, UserRepository userRepository) { + this.apiKeyRepository = apiKeyRepository; + this.userRepository = userRepository; + // BCrypt strength 12 is deliberately chosen for API keys: slow enough to resist brute-force, + // fast enough that per-request validation is acceptable at normal traffic volumes. + this.passwordEncoder = new BCryptPasswordEncoder(12); + this.secureRandom = new SecureRandom(); + } + + // ------------------------------------------------------------------------- + // Public DTOs + // ------------------------------------------------------------------------- + + /** + * Returned once at key creation time. {@code rawKey} is the full plaintext key + * and is never retrievable again after this response. + */ + public record ApiKeyResult( + String name, + String keyIdentifier, + String rawKey, + Instant createdAt, + Instant expiresAt) { + } + + /** Safe metadata about a key — never exposes the hash or the plaintext secret. */ + public record ApiKeyInfo( + String name, + String description, + String keyIdentifier, + Instant createdAt, + Instant expiresAt, + boolean disabled, + Instant lastUsedAt) { + } + + // ------------------------------------------------------------------------- + // Operations + // ------------------------------------------------------------------------- + + /** + * Generates a new API key for the given user and stores only the BCrypt hash + * of its secret in the database. + * + * @return {@link ApiKeyResult} including the one-time plaintext key. + */ + @Transactional + public ApiKeyResult generate(String login, String name, String description, Instant expiresAt) { + UserEntity user = userRepository.findByLogin(login) + .orElseThrow(() -> new IllegalStateException("User not found: " + login)); + + String identifier = generateUniqueIdentifier(); + + byte[] secretBytes = new byte[32]; + secureRandom.nextBytes(secretBytes); + String secret = Base64.getUrlEncoder().withoutPadding().encodeToString(secretBytes); + + String rawKey = KEY_PREFIX + "_" + identifier + "_" + secret; + String keyHash = passwordEncoder.encode(secret); + + ApiKeyEntity entity = new ApiKeyEntity(); + entity.setKeyIdentifier(identifier); + entity.setKeyHash(keyHash); + entity.setUser(user); + entity.setName(name); + entity.setDescription(description); + entity.setCreatedAt(Instant.now()); + entity.setExpiresAt(expiresAt); + entity.setDisabled(false); + apiKeyRepository.save(entity); + + log.info("API key created: identifier={}, user={}", entity.getKeyIdentifier(), login); + return new ApiKeyResult(name, identifier, rawKey, entity.getCreatedAt(), expiresAt); + } + + /** + * Validates a raw API key presented in a request. + * + *

Steps: parse → indexed lookup by identifier → disabled/expiry check → BCrypt verify. + * On success, updates {@code last_used_at} and returns the owning {@link UserEntity}. + * + * @return the owning user DTO, or empty if the key is invalid, disabled, or expired. + */ + @Transactional + public Optional validate(String rawKey) { + ParsedKey parsed = parseKey(rawKey); + if (parsed == null) { + return Optional.empty(); + } + + Optional keyOpt = apiKeyRepository.findByKeyIdentifier(parsed.identifier()); + if (keyOpt.isEmpty()) { + return Optional.empty(); + } + + ApiKeyEntity key = keyOpt.get(); + + if (key.isDisabled()) { + return Optional.empty(); + } + if (key.getExpiresAt() != null && key.getExpiresAt().isBefore(Instant.now())) { + return Optional.empty(); + } + // BCrypt.matches() is the expensive step — it only runs after the O(1) identifier lookup. + if (!passwordEncoder.matches(parsed.secret(), key.getKeyHash())) { + return Optional.empty(); + } + + key.setLastUsedAt(Instant.now()); + apiKeyRepository.save(key); + + return Optional.of(User.fromEntity(key.getUser())); + } + + /** + * Returns metadata for all API keys owned by the given user. + * The hash and plaintext secret are never included. + */ + @Transactional(readOnly = true) + public List list(String login) { + UserEntity user = userRepository.findByLogin(login) + .orElseThrow(() -> new IllegalStateException("User not found: " + login)); + return apiKeyRepository.findByUser(user).stream() + .map(k -> new ApiKeyInfo( + k.getName(), k.getDescription(), k.getKeyIdentifier(), + k.getCreatedAt(), k.getExpiresAt(), k.isDisabled(), k.getLastUsedAt())) + .toList(); + } + + /** + * Disables an API key. Ownership is verified before revoking. + * + * @throws IllegalArgumentException if the key is not found or does not belong to {@code ownerLogin}. + */ + @Transactional + public void revoke(String keyIdentifier, String ownerLogin) { + ApiKeyEntity key = apiKeyRepository.findByKeyIdentifier(keyIdentifier) + .orElseThrow(() -> new IllegalArgumentException("API key not found: " + keyIdentifier)); + if (!key.getUser().getLogin().equals(ownerLogin)) { + throw new IllegalArgumentException("API key " + keyIdentifier + " does not belong to the current user"); + } + key.setDisabled(true); + apiKeyRepository.save(key); + log.info("API key revoked: identifier={}, by user={}", keyIdentifier, ownerLogin); + } + + /** + * Permanently deletes an API key record. Ownership is verified before deletion. + * + * @throws IllegalArgumentException if the key is not found or does not belong to {@code ownerLogin}. + */ + @Transactional + public void delete(String keyIdentifier, String ownerLogin) { + ApiKeyEntity key = apiKeyRepository.findByKeyIdentifier(keyIdentifier) + .orElseThrow(() -> new IllegalArgumentException("API key not found: " + keyIdentifier)); + if (!key.getUser().getLogin().equals(ownerLogin)) { + throw new IllegalArgumentException("API key " + keyIdentifier + " does not belong to the current user"); + } + apiKeyRepository.delete(key); + log.info("API key permanently deleted: identifier={}, by user={}", keyIdentifier, ownerLogin); + } + + // ------------------------------------------------------------------------- + // Internal helpers + // ------------------------------------------------------------------------- + + /** + * Generates a hex-encoded identifier that does not already exist in the database. + * Collisions are astronomically unlikely (64-bit space) but the retry loop handles them. + */ + private String generateUniqueIdentifier() { + for (int attempt = 0; attempt < MAX_IDENTIFIER_RETRIES; attempt++) { + byte[] bytes = new byte[8]; + secureRandom.nextBytes(bytes); + String identifier = HexFormat.of().formatHex(bytes); + if (apiKeyRepository.findByKeyIdentifier(identifier).isEmpty()) { + return identifier; + } + log.warn("API key identifier collision on attempt {}, retrying", attempt + 1); + } + throw new IllegalStateException( + "Failed to generate a unique API key identifier after " + MAX_IDENTIFIER_RETRIES + " attempts"); + } + + private record ParsedKey(String identifier, String secret) { + } + + /** + * Parses a raw key in the form {@code wa__}. + * The identifier is the portion before the first {@code _} after the prefix. + * The secret is everything after that {@code _}, so the secret itself may safely + * contain {@code _} characters (as Base64-URL encoding produces). + */ + private ParsedKey parseKey(String rawKey) { + final String prefixWithDelimiter = KEY_PREFIX + "_"; + if (rawKey == null || !rawKey.startsWith(prefixWithDelimiter)) { + return null; + } + String rest = rawKey.substring(prefixWithDelimiter.length()); + int underscoreIdx = rest.indexOf('_'); + if (underscoreIdx < 1) { + return null; + } + String identifier = rest.substring(0, underscoreIdx); + String secret = rest.substring(underscoreIdx + 1); + if (secret.isBlank()) { + return null; + } + return new ParsedKey(identifier, secret); + } +} diff --git a/src/main/java/org/ohdsi/webapi/security/authc/JwtAuthConfig.java b/src/main/java/org/ohdsi/webapi/security/authc/JwtAuthConfig.java index 7fe05ae0d..df8e5b5d8 100644 --- a/src/main/java/org/ohdsi/webapi/security/authc/JwtAuthConfig.java +++ b/src/main/java/org/ohdsi/webapi/security/authc/JwtAuthConfig.java @@ -65,7 +65,9 @@ import java.net.MalformedURLException; import org.springframework.context.annotation.Primary; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.AuthenticationFilter; import com.nimbusds.jose.jwk.source.ImmutableSecret; +import org.ohdsi.webapi.security.apikey.ApiKeyAuthFilter; @Configuration @EnableScheduling @@ -76,6 +78,7 @@ public class JwtAuthConfig { private final SessionService sessionService; private final UserRepository userRepository; private final HttpSecurityShared httpSecurityShared; + private final ApiKeyAuthFilter apiKeyAuthFilter; @Value("${security.jwt.algorithm:HS256}") private String configuredAlgorithm; @@ -111,10 +114,12 @@ void validateConfiguration() { } } - public JwtAuthConfig(SessionService sessionService, UserRepository userRepository, HttpSecurityShared httpSecurityShared) { + public JwtAuthConfig(SessionService sessionService, UserRepository userRepository, + HttpSecurityShared httpSecurityShared, ApiKeyAuthFilter apiKeyAuthFilter) { this.sessionService = sessionService; this.userRepository = userRepository; this.httpSecurityShared = httpSecurityShared; + this.apiKeyAuthFilter = apiKeyAuthFilter; } /** @@ -279,6 +284,9 @@ public SecurityFilterChain apiChain(HttpSecurity http, auth.anyRequest().authenticated(); } }) + // API key authentication runs before the authentication filter so that + // requests carrying X-API-KEY are resolved without touching the JWT path. + .addFilterBefore(apiKeyAuthFilter, AuthenticationFilter.class) // Configure JWT authentication .oauth2ResourceServer(oauth -> oauth .authenticationEntryPoint(unauthorizedEntryPoint) diff --git a/src/main/resources/db/migration/postgresql/B3.0.0__webapi_baseline.sql b/src/main/resources/db/migration/postgresql/B3.0.0__webapi_baseline.sql index f45f7beda..c1db84ddf 100644 --- a/src/main/resources/db/migration/postgresql/B3.0.0__webapi_baseline.sql +++ b/src/main/resources/db/migration/postgresql/B3.0.0__webapi_baseline.sql @@ -1147,6 +1147,34 @@ CREATE TABLE ${ohdsiSchema}.sec_session CREATE INDEX idx_sec_session_login ON ${ohdsiSchema}.sec_session(login); +CREATE SEQUENCE ${ohdsiSchema}.sec_api_key_sequence + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +CREATE TABLE ${ohdsiSchema}.sec_api_key +( + id bigint NOT NULL, + key_identifier character varying(64) NOT NULL, + key_hash character varying(255) NOT NULL, + user_id bigint NOT NULL, + name character varying(255) NOT NULL, + description character varying(1000), + created_at timestamp with time zone NOT NULL, + expires_at timestamp with time zone, + disabled boolean NOT NULL, + last_used_at timestamp with time zone +); + +COMMENT ON COLUMN ${ohdsiSchema}.sec_api_key.id IS 'Primary key'; +COMMENT ON COLUMN ${ohdsiSchema}.sec_api_key.key_identifier IS 'Public, indexed identifier portion of the API key (not secret)'; +COMMENT ON COLUMN ${ohdsiSchema}.sec_api_key.key_hash IS 'BCrypt hash of the secret portion of the API key'; +COMMENT ON COLUMN ${ohdsiSchema}.sec_api_key.user_id IS 'Foreign key to SEC_USER'; +COMMENT ON COLUMN ${ohdsiSchema}.sec_api_key.name IS 'User-provided label for this key'; +COMMENT ON COLUMN ${ohdsiSchema}.sec_api_key.disabled IS 'True when the key has been revoked'; + -- Legacy permission table, will be empty for baseline, but including it for consistency. CREATE TABLE ${ohdsiSchema}.sec_permission_legacy ( @@ -1432,6 +1460,20 @@ ALTER TABLE ONLY ${ohdsiSchema}.sec_user ALTER TABLE ONLY ${ohdsiSchema}.sec_user_role ADD CONSTRAINT pk_sec_user_role PRIMARY KEY (id); +ALTER TABLE ONLY ${ohdsiSchema}.sec_api_key + ADD CONSTRAINT pk_sec_api_key PRIMARY KEY (id); + +ALTER TABLE ONLY ${ohdsiSchema}.sec_api_key + ALTER COLUMN id SET DEFAULT nextval('${ohdsiSchema}.sec_api_key_sequence'::regclass); + +ALTER TABLE ONLY ${ohdsiSchema}.sec_api_key + ALTER COLUMN disabled SET DEFAULT false; + +ALTER TABLE ONLY ${ohdsiSchema}.sec_api_key + ADD CONSTRAINT uq_sec_api_key_identifier UNIQUE (key_identifier); + +CREATE INDEX idx_sec_api_key_identifier ON ${ohdsiSchema}.sec_api_key (key_identifier); + ALTER TABLE ONLY ${ohdsiSchema}.source ADD CONSTRAINT pk_source PRIMARY KEY (source_id); @@ -1726,6 +1768,9 @@ ALTER TABLE ONLY ${ohdsiSchema}.sec_user_role ALTER TABLE ONLY ${ohdsiSchema}.sec_user_role ADD CONSTRAINT fk_user_role_to_user FOREIGN KEY (user_id) REFERENCES ${ohdsiSchema}.sec_user(id); +ALTER TABLE ONLY ${ohdsiSchema}.sec_api_key + ADD CONSTRAINT fk_sec_api_key_user FOREIGN KEY (user_id) REFERENCES ${ohdsiSchema}.sec_user(id) ON DELETE CASCADE; + -- FK constraints for sec_{entity} tables: role and entity references. -- Both FKs use ON DELETE CASCADE: deleting a role or a parent entity automatically -- removes its permission grants in the corresponding sec_* table. diff --git a/src/main/resources/db/migration/postgresql/V2.99.0006__api_keys.sql b/src/main/resources/db/migration/postgresql/V2.99.0006__api_keys.sql new file mode 100644 index 000000000..465ef41e8 --- /dev/null +++ b/src/main/resources/db/migration/postgresql/V2.99.0006__api_keys.sql @@ -0,0 +1,25 @@ +-- Create the sec_api_key table for personal API key authentication. +-- Keys are split into a public identifier (indexed) and a BCrypt-hashed secret, +-- allowing O(1) lookup without scanning every row. + +CREATE SEQUENCE ${ohdsiSchema}.sec_api_key_sequence START WITH 1 INCREMENT BY 1; + +CREATE TABLE ${ohdsiSchema}.sec_api_key ( + id BIGINT NOT NULL DEFAULT nextval('${ohdsiSchema}.sec_api_key_sequence'), + key_identifier VARCHAR(64) NOT NULL, + key_hash VARCHAR(255) NOT NULL, + user_id BIGINT NOT NULL, + name VARCHAR(255) NOT NULL, + description VARCHAR(1000), + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + expires_at TIMESTAMP WITH TIME ZONE, + disabled BOOLEAN NOT NULL DEFAULT FALSE, + last_used_at TIMESTAMP WITH TIME ZONE, + CONSTRAINT pk_sec_api_key PRIMARY KEY (id), + CONSTRAINT uq_sec_api_key_identifier UNIQUE (key_identifier), + CONSTRAINT fk_sec_api_key_user FOREIGN KEY (user_id) + REFERENCES ${ohdsiSchema}.sec_user(id) ON DELETE CASCADE +); + +-- Explicit index on key_identifier to guarantee O(1) lookup during authentication. +CREATE INDEX idx_sec_api_key_identifier ON ${ohdsiSchema}.sec_api_key (key_identifier); diff --git a/src/test/java/org/ohdsi/webapi/test/ApiKeyIT.java b/src/test/java/org/ohdsi/webapi/test/ApiKeyIT.java new file mode 100644 index 000000000..076358673 --- /dev/null +++ b/src/test/java/org/ohdsi/webapi/test/ApiKeyIT.java @@ -0,0 +1,172 @@ +package org.ohdsi.webapi.test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import java.time.Instant; +import java.util.Date; +import java.util.Map; +import java.util.UUID; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.junit.Before; +import org.junit.Test; +import org.ohdsi.webapi.security.authc.JwtService; +import org.ohdsi.webapi.security.authz.AuthorizationService; +import org.ohdsi.webapi.security.session.SessionService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +/** + * Integration test for personal API key authentication. + * + *

Scenario: + *

    + *
  1. A test user is inserted directly into the database with a known permission.
  2. + *
  3. The user authenticates interactively (JWT session) and POSTs to + * {@code /user/apikeys} to generate a new API key.
  4. + *
  5. The raw key returned in the creation response is then used as the sole + * credential on a request to {@code GET /user/me} via the {@code X-API-KEY} + * header — no JWT, no session.
  6. + *
  7. The response is asserted to contain the correct user login and a non-null + * authorizations block, proving that the API key resolved to the right + * identity and the permission model is intact.
  8. + *
+ */ +public class ApiKeyIT extends WebApiIT { + + @Autowired + private JwtService jwtService; + @Autowired + private SessionService sessionService; + @Autowired + private AuthorizationService authorizationService; + + private static final long API_KEY_USER_ID = -10L; + private static final long API_KEY_ROLE_ID = -110L; + private static final String API_KEY_LOGIN = "apikeyuser"; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** JWT minted for the test user's interactive session — used only for key creation. */ + private String sessionJwt; + + @Before + public void setUpApiKeyUser() { + String schema = getOhdsiSchema(); + + // Insert user + jdbcTemplate.execute( + "INSERT INTO " + schema + ".sec_user (id, login, name, origin) " + + "VALUES (" + API_KEY_USER_ID + ", '" + API_KEY_LOGIN + "', 'API Key Test User', 'SYSTEM') " + + "ON CONFLICT (login) DO NOTHING"); + + // Insert personal role for the user + jdbcTemplate.execute( + "INSERT INTO " + schema + ".sec_role (id, name, system_role) " + + "VALUES (" + API_KEY_ROLE_ID + ", '" + API_KEY_LOGIN + "', false) " + + "ON CONFLICT DO NOTHING"); + + // Assign the role to the user + jdbcTemplate.execute( + "INSERT INTO " + schema + ".sec_user_role (id, user_id, role_id, origin) " + + "SELECT nextval('" + schema + ".sec_user_role_sequence'), " + API_KEY_USER_ID + ", " + API_KEY_ROLE_ID + ", 'SYSTEM' " + + "WHERE NOT EXISTS (SELECT 1 FROM " + schema + ".sec_user_role " + + " WHERE user_id = " + API_KEY_USER_ID + " AND role_id = " + API_KEY_ROLE_ID + ")"); + + // Grant the 'read' permission to the role so we have something assertable in authz + jdbcTemplate.execute( + "INSERT INTO " + schema + ".sec_role_permission (id, role_id, permission_id) " + + "SELECT nextval('" + schema + ".sec_role_permission_sequence'), " + API_KEY_ROLE_ID + ", p.id " + + "FROM " + schema + ".sec_permission p WHERE p.value = 'read' " + + "AND NOT EXISTS (SELECT 1 FROM " + schema + ".sec_role_permission rp " + + " WHERE rp.role_id = " + API_KEY_ROLE_ID + " AND rp.permission_id = p.id)"); + + authorizationService.clearCache(); + + // Mint a short-lived interactive session JWT for the key-creation step only + UUID sessionId = sessionService.createSession(API_KEY_LOGIN); + Date expiresAt = Date.from(Instant.now().plusSeconds(3600)); + sessionJwt = jwtService.generateToken(API_KEY_LOGIN, sessionId.toString(), expiresAt); + } + + @Test + public void apiKeyAuthenticatesCorrectUserOnMeEndpoint() throws Exception { + + // ── Step 1: Create an API key using the interactive JWT session ────────── + + HttpHeaders createHeaders = new HttpHeaders(); + createHeaders.setContentType(MediaType.APPLICATION_JSON); + createHeaders.set("Authorization", "Bearer " + sessionJwt); + + // Key valid for 30 days; expiresInDays is the new API shape + String createBody = "{\"name\":\"it-test-key\",\"expiresInDays\":30}"; + + ResponseEntity createResponse = new TestRestTemplate() + .exchange( + getBaseUri() + "/user/apikeys", + HttpMethod.POST, + new HttpEntity<>(createBody, createHeaders), + String.class); + + assertEquals("API key creation should return 201 Created", + HttpStatus.CREATED, createResponse.getStatusCode()); + + JsonNode createJson = objectMapper.readTree(createResponse.getBody()); + String rawKey = createJson.path("rawKey").asText(null); + assertNotNull("rawKey must be present in the creation response", rawKey); + + // ── Step 2: Use only the raw API key to call GET /user/me ──────────────── + + HttpHeaders apiKeyHeaders = new HttpHeaders(); + apiKeyHeaders.set("X-API-KEY", rawKey); + + ResponseEntity meResponse = new TestRestTemplate() + .exchange( + getBaseUri() + "/user/me", + HttpMethod.GET, + new HttpEntity<>(apiKeyHeaders), + String.class); + + assertEquals("GET /user/me with a valid API key should return 200 OK", + HttpStatus.OK, meResponse.getStatusCode()); + + // ── Step 3: Assert the returned UserInfo belongs to the API key's owner ── + + JsonNode meJson = objectMapper.readTree(meResponse.getBody()); + + String returnedLogin = meJson.path("user").path("login").asText(null); + assertEquals( + "The user login in /user/me response must match the API key owner", + API_KEY_LOGIN, returnedLogin); + + // authz block must be present (may be empty object, but must not be null/missing) + assertNotNull( + "The authz block must be present in the /user/me response", + meJson.path("authz")); + } + + @Test + public void invalidApiKeyIsRejected() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-API-KEY", "wa_deadbeefdeadbeef_notavalidsecretnottavalidsecretnottavalidsecret12"); + + ResponseEntity response = new TestRestTemplate() + .exchange( + getBaseUri() + "/user/me", + HttpMethod.GET, + new HttpEntity<>(headers), + String.class); + + assertEquals("A malformed/invalid API key must result in 401 Unauthorized", + HttpStatus.UNAUTHORIZED, response.getStatusCode()); + } +}