Feature: Developers authenticate against /v1 with named API keys instead of the jwt cookie As a developer integrating with RenewCMS I want to create named API keys and send them as a Bearer token So that I can manage articles, media and users without the UI, with exactly the rights of the user who created the key and never more Background: Given internal/domain/apikey defines the entity APIKey with ID uint32, UserID uint32, Name, Prefix, HashedKey, ExpiresAt *time.Time, RevokedAt *time.Time, LastUsedAt *time.Time and CreatedAt, with snake_case json tags And internal/domain/apikey defines the sentinels apikey.ErrAPIKeyNotFound, apikey.ErrAPIKeyInvalid and apikey.ErrExpiryNotInFuture And apikey.ErrAPIKeyInvalid covers an unknown, malformed, expired or revoked key and a key whose user is deleted or unverified And internal/domain/apikey defines the interface Repository with Create, Get(id), GetByHashedKey(hashedKey), ListByUser(userId), ListAll(), Revoke(id, at), RevokeAllByUser(userId, at) and TouchLastUsed(id, at) And RevokeAllByUser sets RevokedAt to at on every key of that user that is not yet revoked and leaves the RevokedAt of already revoked keys and every other user's keys untouched And internal/domain/apikey defines the interface KeyGenerator with Generate() returning the raw key, its display prefix and its SHA-256 hash, and Hash(rawKey) returning the SHA-256 hash And internal/infrastructure/hashing implements KeyGenerator, asserted with var _ apikey.KeyGenerator = &SHA256KeyGenerator{} And a generated raw key is "rcms_" followed by the base64url encoding, without padding, of 32 random bytes, so it matches ^rcms_[A-Za-z0-9_-]{43}$ And the display prefix is the first 12 characters of the raw key And only the SHA-256 hash and the prefix are persisted, never the raw key And the use cases CreateAPIKey, ListAPIKeys, RevokeAPIKey and AuthenticateAPIKey depend only on apikey.Repository, apikey.KeyGenerator, user.Repository and an injected clock And ResetPasswordUseCase additionally receives apikey.Repository and an injected clock, and after a successful password reset calls RevokeAllByUser(userId, now); it never reaches persistence directly And persistence has a GORM model, a mapper and a repository for the entity, the model is added to the AutoMigrate list in api/dependecyInjection.go, and wiring happens only in api/dependecyInjection.go And a new middleware resolves "Authorization: Bearer rcms_<...>" to a user and places a jwt in the same jwtauth request context the cookie path fills, with the claims user_id and token_version taken from the user's current record And that middleware runs after jwtauth.Verifier and ahead of jwtauth.Authenticator on the /article routes (public and protected), /user, /media, /settings and GET /auth/me And jwtauth.Authenticator, RequireCurrentToken, RequireVerifiedUser, RequireOwner and every handler stay unchanged And a new middleware RequireSession, mounted ahead of jwtauth.Verifier, answers 403 with apperr.SessionRequiredErrorMessage whenever the Authorization header is a Bearer token that starts with "rcms_", whether that key is valid, expired, revoked, unknown or garbage And RequireSession is mounted on the key management routes POST /v1/api-key, GET /v1/api-key and DELETE /v1/api-key/{id}, on the session-only auth routes POST /v1/auth/verify and DELETE /v1/auth/register, and on every /v1/system route (all under /v1/system/update) And a request on those routes without a "Bearer rcms_" header passes through RequireSession unchanged, so cookie-only and jwt-Bearer behaviour there does not change And the public auth routes login, register, registration, logout, password-reset, password-reset/confirm, invite and invite/accept do not use the key middleware or RequireSession and behave exactly as they do without a key header And the routes are mounted under /v1 inside the jwtauth.Verifier, jwtauth.Authenticator, RequireCurrentToken and RequireVerifiedUser group: POST /v1/api-key, GET /v1/api-key and DELETE /v1/api-key/{id} And a Bearer key on a key-accepting route that cannot authenticate gets 401 with apperr.UnauthorizedErrorMessage, and a request without an rcms_ key keeps the existing jwtauth.Authenticator response, status 401, unchanged by this feature And new apperr strings: APIKeyNotFoundErrorMessage = "API key not found", APIKeyExpiryErrorMessage = "The expiry must be a date in the future." and SessionRequiredErrorMessage = "This action needs a real session." And the per-key rate limit is 120 requests per minute, as constants in api/router.go And enableCorsIfConfigured allows the methods GET, POST, PUT, PATCH, DELETE and OPTIONS And users: owner id 1 "marta" (verified), admin id 2 "dev" (verified), admin id 3 "pat" (verified), unverified id 4 "quinn" Scenario: Creating a key returns the full key exactly once Given a valid jwt cookie for admin 2 When the client sends POST /v1/api-key with body {"name": "ci-deploy"} Then the response status is 201 And the header Cache-Control is "no-store" And the body has exactly the keys id, name, prefix, key, created_at, expires_at And name is "ci-deploy", expires_at is null, and key matches ^rcms_[A-Za-z0-9_-]{43}$ And prefix equals the first 12 characters of key And the stored record has user_id 2 and HashedKey equal to the SHA-256 of key And neither the raw key nor the stored hash appears in any later response Scenario: The key can't be retrieved afterwards Given admin 2 created the key "ci-deploy" and holds its raw key When the same jwt cookie sends GET /v1/api-key Then the response status is 200 And the body is a JSON array with one entry whose keys are exactly id, user_id, name, prefix, status, created_at, last_used_at, expires_at, revoked_at And the entry has no "key" and no "hashed_key" field And the raw key and its hash appear nowhere in the body And the entry's prefix is the first 12 characters of the raw key Scenario: Creating a key with an expiry Given a valid jwt cookie for admin 2 When the client sends POST /v1/api-key with body {"name": "temp", "expires_at": ""} Then the response status is 201 And expires_at in the body equals the requested instant Scenario Outline: Creating a key with an invalid body is refused and creates nothing Given a valid jwt cookie for admin 2 When the client sends POST /v1/api-key with Then the response status is 400 And the response body is And no key exists in the repository Examples: | body | message | | body not-json | apperr.BodyErrorMessage | | body {} | text containing 'Name' and 'required' | | body {"name": ""} | text containing 'Name' and 'required' | | body {"name": "<61 characters>"} | text containing 'Name' and 'max' | | body {"name": "x", "expires_at": "yesterday"} | apperr.BodyErrorMessage | | body {"name": "x", "expires_at": ""} | apperr.APIKeyExpiryErrorMessage | Scenario: A request body can't choose the owner or the role of a new key Given a valid jwt cookie for admin 2 When the client sends POST /v1/api-key with body {"name": "sneaky", "user_id": 1, "role": "owner"} Then the response status is 201 And the stored key belongs to user 2 And using that key on POST /v1/user/invite returns 403 with apperr.OwnerRequiredErrorMessage Scenario Outline: Key management needs a verified real session Given When the client sends POST /v1/api-key with body {"name": "x"} Then the response status is And the response body is And no key exists in the repository Examples: | caller | status | message | | no credentials | 401 | the existing Authenticator response | | a jwt cookie for user 4 | 403 | apperr.VerificationRequiredErrorMessage | | a stale token_version cookie | 401 | the existing Authenticator response | Scenario Outline: A valid key acts as its creator on every key-accepting route Given an active key created by admin 2 When the client sends with "Authorization: Bearer " and no cookie Then the response status is And the effect is attributed to user 2 Examples: | method | path | body | status | | GET | /v1/settings | | 200 | | GET | /v1/user | | 200 | | PATCH | /v1/user/me | and body {"display_name": "Dev"} | 200 | | POST | /v1/article | and body {"title": "A valid title", "body": "x"} | 201 | | PATCH | /v1/article/1/publish | | 200 | | DELETE | /v1/article/1 | | 204 | | GET | /v1/media | | 200 | | DELETE | /v1/media/1 | | 204 | Scenario: A key can read its creator through GET /v1/auth/me Given an active key created by admin 2 When the client sends GET /v1/auth/me with that key and no cookie Then the response status is 200 And the body is the same JSON the jwt cookie of admin 2 gets on GET /v1/auth/me And the body describes user 2 "dev" Scenario: A key that can't authenticate gets 401 on GET /v1/auth/me When the client sends GET /v1/auth/me with "Authorization: Bearer rcms_unknown" Then the response status is 401 And the response body is apperr.UnauthorizedErrorMessage Scenario: A key sees offline articles on the public article routes, an anonymous caller does not Given an offline article 5 and an online article 6 And an active key created by admin 2 When the client sends GET /v1/article with that key Then the body lists articles 5 and 6 When the client sends GET /v1/article with no credentials Then the body lists only article 6 When the client sends GET /v1/article/5 with that key Then the response status is 200 When the client sends GET /v1/article/5 with no credentials Then the response status is 404 Scenario: An invalid key on a public article route is refused, not treated as anonymous When the client sends GET /v1/article with "Authorization: Bearer rcms_unknown" Then the response status is 401 And the response body is apperr.UnauthorizedErrorMessage Scenario: An owner's key can do owner-only things Given an active key created by owner 1 When the client sends POST /v1/user/invite with that key and body {"email": "new@example.com"} Then the response status is 201 And the invite's invited_by is 1 Scenario: An admin's key can not do owner-only things Given an active key created by admin 2 When the client sends POST /v1/user/invite with that key and body {"email": "new@example.com"} Then the response status is 403 And the response body is apperr.OwnerRequiredErrorMessage And no invite exists Scenario Outline: A key that can't authenticate gets the same 401 as a bad jwt Given When the client sends GET /v1/settings with "Authorization: Bearer " Then the response status is 401 And the response body is apperr.UnauthorizedErrorMessage And no handler or use case behind the route runs Examples: | state | credential | | no key stored for it | rcms_unknownunknownunknownunknownunknownunknown1 | | a key whose expires_at is one second ago | that key | | a key that was revoked | that key | | a key of user 1 whose user record was deleted | that key | | a key of an unverified user | that key | | nothing after the prefix | rcms_ | | a key with one character changed | that key, last character changed | | a key's hash passed instead of the key | the stored HashedKey, status 401 only | Scenario: Key behaviour does not change the jwt cookie path Given a valid jwt cookie for admin 2 When the client sends GET /v1/settings with only that cookie Then the response status is 200 When the client sends GET /v1/settings with an Authorization header "Bearer " and no cookie Then the response status is 200 When the client sends GET /v1/settings with a stale token_version cookie Then the response status is 401 Scenario: A Bearer key wins over the cookie, and a bad key is not rescued by a good cookie Given an active key created by admin 3 and a valid jwt cookie for owner 1 When the client sends POST /v1/user/invite with both the key and the cookie Then the response status is 403 When the client sends GET /v1/settings with "Authorization: Bearer rcms_unknown" and the owner cookie Then the response status is 401 Scenario Outline: Session-only routes refuse any rcms_ credential with 403 Given When the client sends with "Authorization: Bearer " Then the response status is 403 And the response body is apperr.SessionRequiredErrorMessage And no use case behind the route runs And no key was created, listed or revoked Examples: | state | credential | method | path | body | | an active key of owner 1 | that key | POST | /v1/api-key | and body {"name": "child"} | | an active key of owner 1 | that key | GET | /v1/api-key | | | an active key of owner 1 | that key | DELETE | /v1/api-key/1 | | | an active key of owner 1 | that key | POST | /v1/auth/verify | and body {"code": "x"} | | an active key of owner 1 | that key | DELETE | /v1/auth/register | | | an active key of owner 1 | that key | GET | /v1/system/update | | | an active key of owner 1 | that key | POST | /v1/system/update/check | | | an active key of owner 1 | that key | POST | /v1/system/update/download | | | an active key of owner 1 | that key | POST | /v1/system/update/restart | | | a revoked key | that key | GET | /v1/api-key | | | no key stored for it | rcms_unknownunknownunknownunknownunknownunknown1 | POST | /v1/system/update/check | | | nothing | rcms_ | DELETE | /v1/auth/register | | Scenario Outline: The session-only routes still work as before with a session Given a valid jwt cookie for owner 1 When the client sends with only that cookie Then the response is the same as before this feature And a request with no credentials still gets status 401 from the existing Authenticator Examples: | method | path | body | | POST | /v1/api-key | and body {"name": "x"} | | GET | /v1/api-key | | | GET | /v1/system/update | | | POST | /v1/system/update/check | | Scenario Outline: A key never obtains a session through the public auth routes Given an active key created by owner 1 When the client sends with that key and body Then the response is the same as without the key And no jwt cookie is set unless the credentials in the body are valid And the stored user and password are unchanged Examples: | method | path | body | | POST | /v1/auth/login | {"username": "marta", "password": "wrong-pass-1"} | | POST | /v1/auth/password-reset/confirm | {"email": "marta@example.com", "code": "nope", "password": "newpassword1"} | Scenario: A successful password reset revokes every key of that user Given user 2 has active keys "a" and "b", and each works on GET /v1/settings And user 2's stored PasswordResetCode is the hash of "reset-code-1" with an unexpired expiration When the client sends POST /v1/auth/password-reset/confirm with body {"email": "dev@example.com", "code": "reset-code-1", "password": "newpassword1"} Then the response status is 200 And GET /v1/settings with key "a" returns 401 with apperr.UnauthorizedErrorMessage And GET /v1/settings with key "b" returns 401 with apperr.UnauthorizedErrorMessage Scenario: A password reset leaves other users' keys untouched Given user 2 has an active key "a" and user 3 has an active key "c" And user 2 has a valid password reset code "reset-code-1" When user 2's password reset is confirmed with "reset-code-1" Then GET /v1/settings with key "c" returns 200 And key "c" has no revoked_at Scenario: Keys revoked by a password reset show as revoked Given user 2 has active keys "a" and "b", and a clock at T And user 2 has a valid password reset code "reset-code-1" When user 2's password reset is confirmed with "reset-code-1" And admin 2 logs in again with the new password and sends GET /v1/api-key with the new cookie Then the entries "a" and "b" have status "revoked" and revoked_at T Scenario: A password reset does not move the revoked_at of a key that was already revoked Given user 2 has a key "old" revoked at T0 and an active key "a" And user 2 has a valid password reset code "reset-code-1" When user 2's password reset is confirmed with "reset-code-1" at a later time T Then key "old" still has revoked_at T0 And key "a" has revoked_at T Scenario Outline: A failed password reset confirmation revokes nothing Given user 2 has an active key "a" And user 2's stored PasswordResetCode is the hash of "reset-code-1" with When the client sends POST /v1/auth/password-reset/confirm with Then the response status is not 200 And key "a" has no revoked_at And GET /v1/settings with key "a" returns 200 Examples: | expiration | body | | an unexpired expiration | {"email": "dev@example.com", "code": "wrong", "password": "newpassword1"} | | an expiration in the past | {"email": "dev@example.com", "code": "reset-code-1", "password": "newpassword1"} | | an unexpired expiration | {"email": "nobody@example.com", "code": "reset-code-1", "password": "newpassword1"} | Scenario: Listing as an admin shows only that admin's keys Given keys "a" and "b" created by admin 2 and key "c" created by admin 3 When admin 2 sends GET /v1/api-key with a jwt cookie Then the body lists exactly "a" and "b" Scenario: Listing as the owner shows every user's keys, with the creator's id Given the same three keys and key "d" created by owner 1 When owner 1 sends GET /v1/api-key with a jwt cookie Then the body lists "a", "b", "c" and "d" newest first And each entry has the user_id of its creator Scenario Outline: The list reports each key's state Given a key that is When its creator sends GET /v1/api-key with a jwt cookie Then the entry's status is "", revoked_at is , and last_used_at is Examples: | state | status | revoked_at | last_used | | active, never used | active | null | null | | active, used 5 minutes ago | active | null | that time | | expired one hour ago | expired | null | unchanged | | revoked | revoked | a time | unchanged | | both expired and revoked | revoked | a time | unchanged | Scenario: Revoking a key ends it immediately Given an active key "ci-deploy" created by admin 2 And the key works on GET /v1/settings When admin 2 sends DELETE /v1/api-key/ with a jwt cookie Then the response status is 204 and the body is empty And GET /v1/settings with that key returns 401 with apperr.UnauthorizedErrorMessage And GET /v1/api-key lists it with status "revoked" Scenario: Revoking twice is idempotent Given a key revoked at time T When its creator sends DELETE /v1/api-key/ again Then the response status is 204 And revoked_at is still T Scenario: An admin can't revoke someone else's key, and learns nothing about it Given a key created by admin 3 When admin 2 sends DELETE /v1/api-key/ with a jwt cookie Then the response status is 404 And the response body is apperr.APIKeyNotFoundErrorMessage And the key still works Scenario: The owner can revoke any key Given a key created by admin 3 When owner 1 sends DELETE /v1/api-key/ with a jwt cookie Then the response status is 204 And the key gets 401 on GET /v1/settings Scenario Outline: Revoking with a bad id Given a valid jwt cookie for admin 2 When the client sends DELETE /v1/api-key/ Then the response status is And the response body is Examples: | id | status | message | | abc | 400 | apperr.IdUint32ErrorMessage | | -1 | 400 | apperr.IdUint32ErrorMessage | | 4294967296 | 400 | apperr.IdUint32ErrorMessage | | 999 | 404 | apperr.APIKeyNotFoundErrorMessage | Scenario: Using a key records last_used_at, at most once per minute Given an active key never used, and a fake clock at T When the key authenticates a request at T Then last_used_at is T When it authenticates again at T + 30s Then no write happened and last_used_at is still T When it authenticates again at T + 61s Then last_used_at is T + 61s Scenario: A failure to record last_used_at doesn't fail the request Given an active key and a repository whose TouchLastUsed returns an error When the client sends GET /v1/settings with that key Then the response status is 200 Scenario: A rejected key never records last_used_at Given an expired key and a revoked key When each is used on GET /v1/settings Then each gets 401 and no last_used_at is written Scenario: Each key has its own rate limit Given two active keys "a" and "b" and a limit of N requests per minute per key When key "a" sends N+1 requests to GET /v1/settings Then request N+1 gets 429 with apperr.TooManyRequestsErrorMessage and a Retry-After header And key "b" still gets 200 And a jwt cookie request still gets 200 Scenario: The rate limit does not apply to unauthenticated requests When the client sends N+1 requests with "Authorization: Bearer rcms_unknown" Then every response is 401, never 429 Scenario: The CORS preflight allows PATCH Given settings with CorsAllowedOrigins ["https://app.example.com"] When the client sends OPTIONS /v1/article/1 with Origin "https://app.example.com" and Access-Control-Request-Method "PATCH" Then the response is a successful preflight And Access-Control-Allow-Methods includes PATCH And a preflight for method "TRACE" is not allowed Scenario: Use case: creating a key hashes it and stores no plaintext Given a fake KeyGenerator returning raw "rcms_fake", prefix "rcms_fake", hash "hash:rcms_fake" When CreateAPIKey is called with user 2 and name "ci" Then the repository's Create receives HashedKey "hash:rcms_fake", Prefix "rcms_fake", UserID 2 And the result carries the raw key, which is not stored Scenario: Use case: authenticating returns the user and rejects every invalid case with one sentinel Given a fake KeyGenerator and keys that are unknown, expired, revoked, and owned by a missing or unverified user When AuthenticateAPIKey is called with each raw key Then every call returns apikey.ErrAPIKeyInvalid, asserted with errors.Is And a key expiring exactly at the clock's now is expired And a valid key returns the user from user.Repository Scenario: Use case: a failing repository is not reported as an invalid key Given a repository whose GetByHashedKey returns an unexpected error When AuthenticateAPIKey is called Then the error is returned and errors.Is(err, apikey.ErrAPIKeyInvalid) is false And the middleware answers 500 "Internal server error" Scenario: Use case: ResetPassword revokes the user's keys through the domain repository Given a fake apikey.Repository holding active keys of users 2 and 3, a fake clock at T, and a user.Repository fake with a valid reset code for user 2 When ResetPasswordUseCase.ResetPassword succeeds for user 2 Then the fake apikey.Repository received exactly one RevokeAllByUser(2, T) And user 3's keys are untouched Scenario: Use case: ResetPassword does not call RevokeAllByUser when the reset fails Given a fake apikey.Repository and a user.Repository fake with a wrong or expired reset code When ResetPasswordUseCase.ResetPassword is called Then it returns the existing invalid or expired sentinel And RevokeAllByUser is never called Scenario: Use case: a failure to revoke keys after a reset is reported Given a valid reset code and an apikey.Repository whose RevokeAllByUser returns an error When ResetPasswordUseCase.ResetPassword is called Then the use case returns that error And the password was already changed