diff --git a/README.md b/README.md
index 9f022b4e..a83e0b0c 100644
--- a/README.md
+++ b/README.md
@@ -73,3 +73,13 @@ A clone logs to the console and ships nothing anywhere. The server URL is the on
## Frontend–API communication
Typed clients in `EvilCase.Api.Client` are generated at build time from the API controller sources by a source generator, so client and server cannot drift. Controllers are the single source of truth, DTOs are shared through `EvilCase.Api.Contract`, and controller conventions are enforced by analyzers at error severity. The app is served by the API host, so calls are same-origin and no CORS is involved.
+
+## Integrations over REST
+
+An integration authenticates with a personal API token. Create one in Nastavení → API tokeny (`/settings/api-tokens`) with a name, a scope (Jen čtení or Čtení i zápis) and an expiry of 30, 90 or 365 days; the token is shown once. Send it on every request:
+
+```
+curl -H "Authorization: Bearer evc_…" "https://localhost:5000/api/cases?take=20"
+```
+
+The token acts as its user, tenant included. A read-only token gets 403 on every write, and an unknown, revoked or expired token gets 401. `/api/auth/**` and token management accept the browser session only. `/openapi/v1.json` describes every endpoint and the Bearer scheme, and is anonymous in every environment.
diff --git a/docs/design/README.md b/docs/design/README.md
index 748c53e7..c8326026 100644
--- a/docs/design/README.md
+++ b/docs/design/README.md
@@ -29,7 +29,8 @@ Otevři je v prohlížeči přímo ze souboru.
| [`hledani.html`](hledani.html) | Hledání v horní liště, otevřená nabídka | všechny obrazovky |
| [`kontakty.html`](kontakty.html) | Kontakty | `/contacts` |
| [`detail-kontaktu.html`](detail-kontaktu.html) | Detail kontaktu | `/contacts/{id}` |
-| [`nastaveni.html`](nastaveni.html) | Nastavení, sekce štítků | `/settings` |
+| [`nastaveni.html`](nastaveni.html) | Nastavení, sekce štítků | `/settings/labels` |
+| [`nastaveni-api-tokeny.html`](nastaveni-api-tokeny.html) | Nastavení, sekce API tokenů | `/settings/api-tokens` |
Text v hranatých závorkách je zástupný: vzorová data pro něj hodnotu nemají. Implementace na
jeho místě ukazuje skutečnou hodnotu, nebo prázdný stav podle SDD dané agendy.
diff --git a/docs/design/nastaveni-api-tokeny.html b/docs/design/nastaveni-api-tokeny.html
new file mode 100644
index 00000000..8f081e17
--- /dev/null
+++ b/docs/design/nastaveni-api-tokeny.html
@@ -0,0 +1,269 @@
+
+
+
diff --git a/docs/sdd/sdd-005-api-konvence.md b/docs/sdd/sdd-005-api-konvence.md
index 6c252577..86f85fd7 100644
--- a/docs/sdd/sdd-005-api-konvence.md
+++ b/docs/sdd/sdd-005-api-konvence.md
@@ -19,12 +19,14 @@ Tvary API a klienta.
jako Problem Details, nikdy `index.html` frontendu.
- Business služba vrací kontraktní DTO; žádná druhá sada modelů.
- Endpoint bez výslovné výjimky vyžaduje přihlášení. Anonymní jsou přihlášení, obnova tokenu,
- odhlášení, health checks, upload klientských logů, 404 pod `/api` a v Development `/openapi`
- a `/scalar`.
+ odhlášení, health checks, upload klientských logů, 404 pod `/api`, v každém prostředí
+ `/openapi/v1.json` a v Development `/scalar`.
+- Přihlášení nese cookie, nebo API token v hlavičce `Authorization: Bearer` (SDD-006). OpenAPI
+ dokument deklaruje schéma Bearer.
- Založení odpovídá 201 s hlavičkou `Location` na detail nového záznamu; u souboru a verze míří na
- stažení obsahu, detail souboru není. Upload, který nic neuloží, protože se obsah shoduje
- s aktuální verzí, odpovídá 200. Komentář odpovídá 204 i na založení. Editace a smazání
- odpovídají 204.
+ stažení obsahu, detail souboru není. Založení API tokenu odpovídá 201 bez `Location`, protože
+ token nemá detail. Upload, který nic neuloží, protože se obsah shoduje s aktuální verzí,
+ odpovídá 200. Komentář odpovídá 204 i na založení. Editace a smazání odpovídají 204.
- V těle jde hodnota výčtu jako název; číslo ani neznámý název se nepřijme (400). V query
stringu ji váže framework, který číslo bere.
- Každý výpis bere stránku, směr řazení, svůj řadicí klíč a hledaný text; k tomu zúžení své
@@ -40,7 +42,8 @@ Tvary API a klienta.
Limitem prochází celé `/api/auth/**`, přihlášené endpointy včetně, a upload klientských logů;
partition je adresa volajícího: přihlášení 5 za minutu, obnova tokenu 60, zbytek `/api/auth/**` 10,
upload klientských logů 120.
-Vše ostatní je bez limitu, health checks včetně. Odmítnutí je 429 s `Retry-After`.
+Vše ostatní je bez limitu, health checks a požadavky s API tokenem včetně. Odmítnutí je 429
+s `Retry-After`.
### Zdroje
@@ -53,6 +56,7 @@ Vše ostatní je bez limitu, health checks včetně. Odmítnutí je 429 s `Retry
| Komentáře | na vlastníku, `…/comments`, `…/comments/{id}` |
| Hledání | `/api/search?q=`, viz [SDD-021](sdd-021-vyhledavani.md) |
| Štítky | `/api/labels`, `/api/labels/{id}`; přidělení na vlastníku `…/labels` |
+| API tokeny | `/api/api-tokens`, zrušení `/api/api-tokens/{id}`; jen přihlášení v prohlížeči (SDD-006) |
- Id v routách je `Guid`.
- Tenanta i uživatele dodává kontext požadavku; endpoint ani dotaz nikdy neberou id tenanta
diff --git a/docs/sdd/sdd-006-tenance-a-ucty.md b/docs/sdd/sdd-006-tenance-a-ucty.md
index d6423ed7..66271abd 100644
--- a/docs/sdd/sdd-006-tenance-a-ucty.md
+++ b/docs/sdd/sdd-006-tenance-a-ucty.md
@@ -2,13 +2,14 @@
- **Stav:** platí
- **Milníky:** M2
-- **Související SDD:** [003](sdd-003-testovani.md), [004](sdd-004-validace-a-chyby.md),
- [005](sdd-005-api-konvence.md), [007](sdd-007-domenovy-model.md)
+- **Související SDD:** [002](sdd-002-logovani-a-observabilita.md), [003](sdd-003-testovani.md),
+ [004](sdd-004-validace-a-chyby.md), [005](sdd-005-api-konvence.md),
+ [007](sdd-007-domenovy-model.md), [019](sdd-019-stitky.md)
## Rozsah
-Účty, tenanti, uživatelé, izolace dat mezi tenanty a autentizace. Doménové entity popisuje
-SDD-007.
+Účty, tenanti, uživatelé, izolace dat mezi tenanty, autentizace a API tokeny. Doménové entity
+popisuje SDD-007.
## Popis
@@ -19,7 +20,8 @@ SDD-007.
- **User** — tenantová entita, patří právě jednomu tenantu: e-mail, hash hesla, role, lockout
a `TenantId`.
-Mimo tenant stojí jen Account, Tenant a refresh token; každá jiná entita nese `TenantId`.
+Mimo tenant stojí jen Account, Tenant, refresh token a API token; každá jiná entita nese
+`TenantId`.
Vlastníka `UserId` nese každá kromě kontaktu; kontakt patří tenantu (SDD-011). Obojí plní
zápis, ne volající. Tenanta ani uživatele nelze smazat, dokud drží řádky.
@@ -36,8 +38,8 @@ správu účtů, žádná registrace.
Data nesmí utéct mezi tenanty; únik je kritická chyba.
- Čtení nikdy nevidí řádek cizího tenantu; tenanta i uživatele dodává kontext požadavku.
- Výjimka je hledání uživatele, které tenanta ještě znát nemůže — přihlášení, obnova tokenu
- a oba seedy.
+ Výjimka je hledání uživatele, které tenanta ještě znát nemůže — přihlášení, obnova tokenu,
+ ověření API tokenu a oba seedy.
- Access token nese tenant claim i subject claim.
- Zápis doplní `TenantId` a `UserId` nové entity z kontextu a odmítne řádek, který jmenuje
cizího tenanta nebo cizího uživatele.
@@ -47,7 +49,7 @@ Data nesmí utéct mezi tenanty; únik je kritická chyba.
přes celé nasazení a ukládá se oříznutý a malými písmeny, takže přihlášení nerozlišuje
velikost písmen.
- Konvenční test hlídá, že žádná tenantová entita filtr nepostrádá a že mimo tenant stojí jen
- Account, Tenant a refresh token (SDD-003).
+ Account, Tenant, refresh token a API token (SDD-003).
### Autentizace
@@ -57,7 +59,7 @@ Heslo je uložené jako hash odolný proti hrubé síle. Access token nese tenan
z řádku uživatele při každém vydání, obnovu tokenu včetně. CSRF obranu drží `SameSite=Strict`,
same-origin a kontrola `Sec-Fetch-Site` (případně `Origin`) na zápisech do `/api`: zápis mimo
vlastní origin odpoví 403. Antiforgery token není. Hlavička `Authorization: Bearer` zůstává
-přijatá pro klienty mimo prohlížeč.
+přijatá pro klienty mimo prohlížeč; nese access token, nebo API token.
Přihlášení vydrží 30 dní od posledního použití a nejdéle 90 dní od přihlášení. Access token
platí 15 minut a aplikace ho obnovuje sama; obnova z více záložek téhož prohlížeče uživatele
@@ -67,6 +69,28 @@ Pět po sobě jdoucích neúspěšných přihlášení účet na 15 minut uzamkn
423 (SDD-004) a úspěšné přihlášení počitadlo vynuluje. Uzamčení blokuje jen přihlášení heslem;
živá přihlášení neukončí a jejich obnova běží dál.
+### API tokeny
+
+Integrace a AI agent se přihlašují osobním API tokenem. Token patří jednomu uživateli a jedná
+jako on: tenanta, uživatele i roli bere z řádku uživatele při každém požadavku. Token nese název
+(nejvýše 64 znaků), oprávnění „Jen čtení“ nebo „Čtení i zápis“ a povinnou platnost 30, 90 nebo
+365 dní. Uživatel drží nejvýše 20 neprošlých tokenů; další založení odpoví 400.
+
+Token je `evc_` a 256 náhodných bitů v base64url. Ukáže se jednou, při založení; uloží se jen
+jeho hash. Posílá se v hlavičce `Authorization: Bearer` a schéma vybere jeho prefix; token
+v hlavičce vyhraje nad cookie přihlášení. Neznámý, zrušený nebo prošlý token odpoví 401. Zrušení
+token smaže a platí od dalšího požadavku, nic token necachuje.
+
+- Token jen pro čtení odpoví 403 na každý zápis: POST, PUT, PATCH, DELETE.
+- Na `/api/auth/**` a na správu tokenů odpoví token 403; obojí zůstává přihlášení v prohlížeči.
+- Uzamčení účtu token nezastaví, stejně jako živé přihlášení.
+- Požadavky s tokenem limit požadavků nemají (SDD-005).
+- „Naposledy použit“ se zapíše nejvýše jednou za minutu.
+- Logovací scope požadavku s tokenem nese id a název tokenu (SDD-002); jiný záznam o změnách
+ agenta není. Token sám se do logu nedostane nikdy.
+
+Tokeny spravuje sekce nastavení API tokeny (SDD-019).
+
## Rozhodnutí
- Uživatelé v tenantu: více uživatelů sdílí tenant / 1 uživatel = 1 tenant. Platí
@@ -79,6 +103,10 @@ Pět po sobě jdoucích neúspěšných přihlášení účet na 15 minut uzamkn
- Plnění `TenantId` a `UserId`: volající / zápis sám. Platí zápis sám, jen na nové řádce.
- Změna a smazání cizího záznamu uvnitř tenantu: jen vlastník / kdokoli z tenantu. Platí kdokoli
z tenantu, komentář jen jeho autor (SDD-013).
+- Uložení API tokenu: pomalý hash jako u hesla / SHA-256. Platí SHA-256: 256 náhodných bitů nejde
+ uhodnout, a ověření běží při každém požadavku.
+- API token na `/api/auth/**` a na správě tokenů: 401 / 403. Platí 403: token je platný, jen tam
+ nesmí.
## Dopady
diff --git a/docs/sdd/sdd-016-navigace-a-vzhled.md b/docs/sdd/sdd-016-navigace-a-vzhled.md
index bd18c6d6..44507576 100644
--- a/docs/sdd/sdd-016-navigace-a-vzhled.md
+++ b/docs/sdd/sdd-016-navigace-a-vzhled.md
@@ -28,8 +28,10 @@ URL nesou UUID entit, nikdy jejich čísla:
| `/acts/{actId}/edit` | editace úkonu |
| `/contacts` | kontakty |
| `/contacts/{id}` | detail kontaktu |
-| `/settings` | nastavení (SDD-019) |
-| `/settings/export` | export dat tenanta (SDD-019, SDD-022) |
+| `/settings` | přesměruje na `/settings/labels` |
+| `/settings/labels` | nastavení, štítky (SDD-019) |
+| `/settings/api-tokens` | nastavení, API tokeny (SDD-019) |
+| `/settings/export` | nastavení, export dat tenanta (SDD-019, SDD-022) |
| `/login` | přihlášení |
Neznámá routa vykreslí stav nenalezeno uvnitř aplikace, neznámé id v routě prázdný stav
diff --git a/docs/sdd/sdd-019-stitky.md b/docs/sdd/sdd-019-stitky.md
index e3c5b866..9517c090 100644
--- a/docs/sdd/sdd-019-stitky.md
+++ b/docs/sdd/sdd-019-stitky.md
@@ -2,13 +2,14 @@
- **Stav:** platí
- **Milníky:** M8
-- **Související SDD:** [007](sdd-007-domenovy-model.md), [009](sdd-009-spisy.md),
- [010](sdd-010-ukony.md), [016](sdd-016-navigace-a-vzhled.md), [022](sdd-022-export.md)
+- **Související SDD:** [006](sdd-006-tenance-a-ucty.md), [007](sdd-007-domenovy-model.md),
+ [009](sdd-009-spisy.md), [010](sdd-010-ukony.md), [016](sdd-016-navigace-a-vzhled.md),
+ [022](sdd-022-export.md)
## Rozsah
-Entita štítku, jeho přidělení spisu a úkonu, sekce nastavení a zobrazení štítků. Stránky spisu
-drží SDD-009, stránky úkonu SDD-010, routy a menu SDD-016.
+Entita štítku, jeho přidělení spisu a úkonu, nastavení a jeho sekce a zobrazení štítků. Stránky
+spisu drží SDD-009, stránky úkonu SDD-010, routy a menu SDD-016.
## Popis
@@ -25,18 +26,26 @@ ničem. Tutéž dvojici nese nejvýše jednou. Spis ani úkon nemá počet ští
### Nastavení
-`/settings` je agenda nastavení tenantu se sekcemi Štítky (`/settings`) a Export
-(`/settings/export`). Na desktopu je mezi sekcemi svislý panel, na mobilu řada pilulek.
+Nastavení nese sekce Štítky (`/settings/labels`), API tokeny (`/settings/api-tokens`) a Export
+(`/settings/export`), každou na vlastní stránce se společnou navigací sekcí. Na desktopu je
+navigace svislým panelem, na mobilu řadou pilulek. `/settings` přesměruje na Štítky.
+
+Sekce Štítky ukazuje štítky seřazené podle názvu, u každého počet spisů a počet úkonů, které ho
+nesou, a nechá štítek založit, přejmenovat, přebarvit a smazat. Název, který už nese jiný štítek,
+se odmítne 409 (SDD-004). Smazání štítku ho sejme ze všech spisů a úkonů, které ho nesly; spis ani
+úkon tím nezaniká. Potvrzuje se jako každé mazání a potvrzení jmenuje, kolik spisů a úkonů štítek
+nese.
+
+Sekce API tokeny ukazuje tokeny přihlášeného uživatele (SDD-006), nejnovější nahoře: název,
+oprávnění, kdy token vznikl, do kdy platí a kdy byl naposledy použit. Prošlý token nese
+„Vypršel“, dosud nepoužitý „Nepoužit“. „Nový token“ bere název (povinný), oprávnění (výchozí
+Jen čtení) a platnost 30, 90 nebo 365 dní (výchozí 90). Po založení se token ukáže jedinkrát,
+s tlačítkem pro zkopírování a upozorněním, že ho uživatel znovu neuvidí. „Zrušit“ token zruší po
+potvrzení jako každé mazání; zrušený token ze seznamu zmizí.
Sekce Export popíše, co archiv nese a co ne, a nese tlačítko Exportovat vše s názvem souboru,
pod kterým se archiv stáhne. Export řídí [SDD-022](sdd-022-export.md).
-Sekce Štítky ukazuje štítky
-seřazené podle názvu, u každého počet spisů a počet úkonů, které ho nesou, a nechá štítek
-založit, přejmenovat, přebarvit a smazat. Název, který už nese jiný štítek, se odmítne 409
-(SDD-004). Smazání štítku ho sejme ze všech spisů a úkonů, které ho nesly; spis ani úkon tím
-nezaniká. Potvrzuje se jako každé mazání a potvrzení jmenuje, kolik spisů a úkonů štítek nese.
-
### Přidělení
Štítky se přidělují na dvou místech:
@@ -67,7 +76,8 @@ spisu nebo úkonu, na každé šířce.
- Smazání štítku, který něco nese: zákaz / smazání i s jeho přiděleními. Platí smazání
i s přiděleními; spis ani úkon, který štítek nesl, tím nezaniká.
- Zápis přidělení: přidání a odebrání po jednom / celá množina. Platí celá množina.
-- Nastavení: vlastní stránka / sekce v profilu uživatele. Platí vlastní stránka `/settings`.
+- Nastavení: vlastní stránka / sekce v profilu uživatele. Platí vlastní stránky pod `/settings/`,
+ jedna na sekci.
## Dopady
diff --git a/src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenCreateRequest.cs b/src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenCreateRequest.cs
new file mode 100644
index 00000000..32cb4298
--- /dev/null
+++ b/src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenCreateRequest.cs
@@ -0,0 +1,19 @@
+using System.ComponentModel.DataAnnotations;
+using EvilBrains.EvilCase.Domain.ApiTokens;
+
+namespace EvilBrains.EvilCase.Api.Contract.ApiTokens;
+
+public sealed record ApiTokenCreateRequest
+{
+ [Required]
+ [StringLength(ApiTokenLimits.MaxNameLength)]
+ public required string Name { get; init; }
+
+ public required ApiTokenScope Scope { get; init; }
+
+ ///
+ /// One of .
+ ///
+ [AllowedValues(30, 90, 365)]
+ public required int ExpiresInDays { get; init; }
+}
diff --git a/src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenCreated.cs b/src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenCreated.cs
new file mode 100644
index 00000000..df9bff24
--- /dev/null
+++ b/src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenCreated.cs
@@ -0,0 +1,11 @@
+namespace EvilBrains.EvilCase.Api.Contract.ApiTokens;
+
+public sealed record ApiTokenCreated
+{
+ public required ApiTokenItem Token { get; init; }
+
+ ///
+ /// The only response that carries the token; the server keeps its hash alone.
+ ///
+ public required string Secret { get; init; }
+}
diff --git a/src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenItem.cs b/src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenItem.cs
new file mode 100644
index 00000000..e7da673b
--- /dev/null
+++ b/src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenItem.cs
@@ -0,0 +1,18 @@
+using EvilBrains.EvilCase.Domain.ApiTokens;
+
+namespace EvilBrains.EvilCase.Api.Contract.ApiTokens;
+
+public sealed record ApiTokenItem
+{
+ public required Guid ApiTokenId { get; init; }
+
+ public required string Name { get; init; }
+
+ public required ApiTokenScope Scope { get; init; }
+
+ public required DateTime Created { get; init; }
+
+ public required DateTime Expires { get; init; }
+
+ public DateTime? LastUsed { get; init; }
+}
diff --git a/src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenLimits.cs b/src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenLimits.cs
new file mode 100644
index 00000000..4b3495fa
--- /dev/null
+++ b/src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenLimits.cs
@@ -0,0 +1,21 @@
+namespace EvilBrains.EvilCase.Api.Contract.ApiTokens;
+
+public static class ApiTokenLimits
+{
+ ///
+ /// Mirrors ApiToken.Name's MaxLength.
+ ///
+ public const int MaxNameLength = 64;
+
+ ///
+ /// Unexpired tokens one user may hold.
+ ///
+ public const int MaxActive = 20;
+
+ public const int DefaultLifetimeDays = 90;
+
+ ///
+ /// names the same values.
+ ///
+ public static readonly IReadOnlyList LifetimeDays = [30, DefaultLifetimeDays, 365];
+}
diff --git a/src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenListResponse.cs b/src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenListResponse.cs
new file mode 100644
index 00000000..8c274a74
--- /dev/null
+++ b/src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenListResponse.cs
@@ -0,0 +1,6 @@
+namespace EvilBrains.EvilCase.Api.Contract.ApiTokens;
+
+public sealed record ApiTokenListResponse
+{
+ public required IReadOnlyList Items { get; init; }
+}
diff --git a/src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenProblems.cs b/src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenProblems.cs
new file mode 100644
index 00000000..ccfaef5a
--- /dev/null
+++ b/src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenProblems.cs
@@ -0,0 +1,8 @@
+namespace EvilBrains.EvilCase.Api.Contract.ApiTokens;
+
+public static class ApiTokenProblems
+{
+ public const string NotFound = "API token not found";
+
+ public const string LimitReached = "API token limit reached";
+}
diff --git a/src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenRoute.cs b/src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenRoute.cs
new file mode 100644
index 00000000..b1afd16a
--- /dev/null
+++ b/src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenRoute.cs
@@ -0,0 +1,14 @@
+namespace EvilBrains.EvilCase.Api.Contract.ApiTokens;
+
+///
+/// Shared with the API token boundary, which keeps a token out of its own management.
+///
+public static class ApiTokenRoute
+{
+ ///
+ /// Relative, as the client generator requires.
+ ///
+ public const string Template = "api/api-tokens";
+
+ public const string Path = "/" + Template;
+}
diff --git a/src/Api/EvilCase.Api.Contract/User/AuthClaims.cs b/src/Api/EvilCase.Api.Contract/User/AuthClaims.cs
index 85f91674..87a04f1f 100644
--- a/src/Api/EvilCase.Api.Contract/User/AuthClaims.cs
+++ b/src/Api/EvilCase.Api.Contract/User/AuthClaims.cs
@@ -17,4 +17,13 @@ public static class AuthClaims
public const string AuthSessionId = "sid";
public const string Tenant = "tenant";
+
+ ///
+ /// Only a principal an API token authenticated carries this and the two below.
+ ///
+ public const string ApiTokenId = "api_token_id";
+
+ public const string ApiTokenName = "api_token_name";
+
+ public const string ApiTokenScope = "api_token_scope";
}
diff --git a/src/Api/EvilCase.Api/Bootstrap.cs b/src/Api/EvilCase.Api/Bootstrap.cs
index 30730a5a..eff2a580 100644
--- a/src/Api/EvilCase.Api/Bootstrap.cs
+++ b/src/Api/EvilCase.Api/Bootstrap.cs
@@ -11,6 +11,7 @@
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
+using Microsoft.OpenApi;
using Scalar.AspNetCore;
namespace EvilBrains.EvilCase.Api;
@@ -20,6 +21,8 @@ public static class Bootstrap
// The name browser logs are recorded under; it identifies the deployment, not the library.
private const string ClientSourceContext = "EvilBrains.EvilCase.App.Client";
+ private const string BearerSecurityScheme = "Bearer";
+
// A RequestDelegate: a minimal-API handler would have to bind the unused {**path}.
private static readonly RequestDelegate NotFound = static async context =>
{
@@ -60,6 +63,17 @@ public static IServiceCollection ConfigureServices(this IServiceCollection servi
options.AddDocumentTransformer(static async (document, _, _) =>
{
document.Info.Title = "EvilCase API";
+
+ document.Components ??= new OpenApiComponents();
+ document.Components.SecuritySchemes ??= new Dictionary(StringComparer.Ordinal);
+ document.Components.SecuritySchemes[BearerSecurityScheme] = new OpenApiSecurityScheme
+ {
+ Type = SecuritySchemeType.Http,
+ Scheme = "bearer",
+ Description = "A personal API token (evc_…) created in Nastavení → API tokeny.",
+ };
+
+ document.Security = [new OpenApiSecurityRequirement { [new OpenApiSecuritySchemeReference(BearerSecurityScheme, document)] = [] }];
});
});
@@ -84,6 +98,11 @@ public static IEndpointRouteBuilder MapEvilCaseApi(this IEndpointRouteBuilder en
{
endpoints.MapControllers();
+ // Anonymous in every environment, so an integration reads the contract before it holds a token.
+ endpoints
+ .MapOpenApi()
+ .AllowAnonymous();
+
// Liveness runs no check: the process answering is the signal. A database check here would
// restart every instance at once on a brief outage.
endpoints
@@ -114,10 +133,6 @@ public static IEndpointRouteBuilder MapEvilCaseApi(this IEndpointRouteBuilder en
public static IEndpointRouteBuilder MapEvilCaseApiReference(this IEndpointRouteBuilder endpoints)
{
// Anonymous, so mapped in Development only.
- endpoints
- .MapOpenApi()
- .AllowAnonymous();
-
endpoints
.MapScalarApiReference()
.AllowAnonymous();
diff --git a/src/Api/EvilCase.Api/Controllers/ApiTokensController.cs b/src/Api/EvilCase.Api/Controllers/ApiTokensController.cs
new file mode 100644
index 00000000..945f5bdb
--- /dev/null
+++ b/src/Api/EvilCase.Api/Controllers/ApiTokensController.cs
@@ -0,0 +1,65 @@
+using System.Diagnostics;
+using EvilBrains.ApiClient;
+using EvilBrains.EvilCase.Api.Contract.ApiTokens;
+using EvilBrains.EvilCase.Auth;
+using EvilBrains.EvilCase.Domain.Users;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace EvilBrains.EvilCase.Api.Controllers;
+
+///
+/// A signed-in session manages its own tokens; a token never reaches here (SDD-006).
+///
+[ApiController]
+[GenerateApiClient]
+[Route(ApiTokenRoute.Template)]
+public class ApiTokensController : ControllerBase
+{
+ [HttpGet("")]
+ public async Task ListApiTokens(
+ [FromServices] IApiTokenService apiTokens, [FromServices] IUserContext userContext, CancellationToken token)
+ {
+ var items = await apiTokens.ListApiTokens(userContext.UserId, token);
+
+ return new ApiTokenListResponse { Items = items };
+ }
+
+ [HttpPost("")]
+ [ProducesResponseType(StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ public async Task> CreateApiToken(
+ [FromServices] IApiTokenService apiTokens,
+ [FromServices] IUserContext userContext,
+ [FromBody] ApiTokenCreateRequest request,
+ CancellationToken token)
+ {
+ var result = await apiTokens.CreateApiToken(userContext.UserId, request, token);
+
+ return result.Outcome switch
+ {
+ ApiTokenCreateOutcome.Created => this.StatusCode(StatusCodes.Status201Created, result.Created),
+ ApiTokenCreateOutcome.LimitReached => this.Problem(
+ detail: $"At most {ApiTokenLimits.MaxActive} unexpired tokens per user.",
+ statusCode: StatusCodes.Status400BadRequest,
+ title: ApiTokenProblems.LimitReached),
+ _ => throw new UnreachableException(),
+ };
+ }
+
+ [HttpDelete("{apiTokenId:guid}")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task RevokeApiToken(
+ [FromServices] IApiTokenService apiTokens, [FromServices] IUserContext userContext, [FromRoute] Guid apiTokenId, CancellationToken token)
+ {
+ var outcome = await apiTokens.RevokeApiToken(userContext.UserId, apiTokenId, token);
+
+ return outcome switch
+ {
+ ApiTokenRevokeOutcome.Revoked => this.NoContent(),
+ ApiTokenRevokeOutcome.NotFound => this.Problem(statusCode: StatusCodes.Status404NotFound, title: ApiTokenProblems.NotFound),
+ _ => throw new UnreachableException(),
+ };
+ }
+}
diff --git a/src/App/EvilCase.App/Components/ApiTokenCreatedModal.razor b/src/App/EvilCase.App/Components/ApiTokenCreatedModal.razor
new file mode 100644
index 00000000..35db3e58
--- /dev/null
+++ b/src/App/EvilCase.App/Components/ApiTokenCreatedModal.razor
@@ -0,0 +1,73 @@
+@using EvilBrains.EvilCase.Api.Contract.ApiTokens
+@using Microsoft.JSInterop
+@inject IClipboardWriter Clipboard
+@inject ILogger Logger
+
+
+
+
+
+
+
Zkopírujte si token teď. Po zavření okna už ho znovu neuvidíte.
- Archiv obsahuje spisy s podspisy a úkony, komentáře, soubory se všemi verzemi, kontakty a štítky.
- Každý spis, úkon a kontakt má vlastní soubor JSON, takže se archiv dá číst i bez aplikace.
-
-
- Historie změn, smazané záznamy a uživatelé se neexportují. Export zachytí stav ve chvíli, kdy začne stahování.
-
-
- Exportovat vše
- Stáhne se jako @this.FileName
-
-
-
-
-
-
+
+
+
+
+
Export
+ Všechna data v jednom archivu ZIP
+
+
+
+
+
+ Archiv obsahuje spisy s podspisy a úkony, komentáře, soubory se všemi verzemi, kontakty a štítky.
+ Každý spis, úkon a kontakt má vlastní soubor JSON, takže se archiv dá číst i bez aplikace.
+
+
+ Historie změn, smazané záznamy a uživatelé se neexportují. Export zachytí stav ve chvíli, kdy začne stahování.
+
+
+ Exportovat vše
+ Stáhne se jako @this.FileName
+
+
+
+
@code {
diff --git a/src/App/EvilCase.App/wwwroot/css/ec-components.css b/src/App/EvilCase.App/wwwroot/css/ec-components.css
index 0689a5d8..9b98797c 100644
--- a/src/App/EvilCase.App/wwwroot/css/ec-components.css
+++ b/src/App/EvilCase.App/wwwroot/css/ec-components.css
@@ -3210,7 +3210,7 @@ html.ec-scroll-locked {
.ec-settings-nav-item {
display: flex;
align-items: center;
- gap: var(--ec-space-3);
+ gap: var(--ec-settings-nav-gap);
height: var(--ec-control-lg);
padding: 0 var(--ec-space-4);
border-radius: var(--ec-radius-lg);
@@ -3364,6 +3364,173 @@ html.ec-scroll-locked {
height: var(--ec-control-md);
}
+.ec-token-row {
+ grid-template-columns: minmax(0, 1fr) var(--ec-token-col-scope) var(--ec-token-col-created) var(--ec-token-col-expires) var(--ec-token-col-used) var(--ec-token-col-actions);
+}
+
+.ec-settings-row.ec-token-row {
+ min-height: var(--ec-token-row-height);
+}
+
+.ec-token-main {
+ display: flex;
+ flex-direction: column;
+ gap: var(--ec-space-1);
+ min-width: 0;
+}
+
+.ec-token-name {
+ overflow: hidden;
+ font-size: var(--ec-text-md);
+ font-weight: var(--ec-weight-semibold);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.ec-token-name-expired {
+ color: var(--ec-text-muted);
+}
+
+.ec-token-meta {
+ display: none;
+ flex-direction: column;
+ gap: var(--ec-space-1);
+ font-size: var(--ec-text-xs);
+ color: var(--ec-text-muted);
+}
+
+.ec-token-meta-line {
+ display: flex;
+ align-items: center;
+ gap: var(--ec-space-2);
+}
+
+.ec-token-cell {
+ font-size: var(--ec-text-md);
+ font-variant-numeric: tabular-nums;
+ color: var(--ec-text-soft);
+}
+
+.ec-token-cell-unused {
+ color: var(--ec-token-unused-text);
+}
+
+.ec-token-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--ec-space-2);
+ height: var(--ec-token-chip-height);
+ padding: 0 var(--ec-space-3);
+ border-radius: var(--ec-radius-sm);
+ font-size: var(--ec-text-xs);
+ font-weight: var(--ec-weight-medium);
+ white-space: nowrap;
+}
+
+.ec-token-scope-read {
+ background: var(--ec-token-read-bg);
+ color: var(--ec-token-read-text);
+}
+
+.ec-token-scope-write {
+ background: var(--ec-token-write-bg);
+ color: var(--ec-token-write-text);
+}
+
+.ec-token-expired {
+ background: var(--ec-token-expired-bg);
+ color: var(--ec-token-expired-text);
+ font-weight: var(--ec-weight-semibold);
+}
+
+.ec-token-expired::before {
+ content: "";
+ width: var(--ec-space-3);
+ height: var(--ec-space-3);
+ border-radius: var(--ec-radius-pill);
+ background: currentColor;
+}
+
+.ec-token-subtitle-short {
+ display: none;
+}
+
+.ec-token-scopes {
+ display: flex;
+ flex-direction: column;
+ gap: var(--ec-space-3);
+ margin: 0;
+ padding: 0;
+ border: 0;
+}
+
+.ec-token-scopes > legend {
+ margin-bottom: var(--ec-space-3);
+ padding: 0;
+}
+
+.ec-token-scope-option {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--ec-space-4);
+ padding: var(--ec-space-4) var(--ec-token-option-pad);
+ border: 1px solid var(--ec-border-strong);
+ border-radius: var(--ec-radius-lg);
+ cursor: pointer;
+}
+
+.ec-token-scope-option:has(input:checked) {
+ padding: calc(var(--ec-space-4) - 1px) calc(var(--ec-token-option-pad) - 1px);
+ border: 2px solid var(--ec-accent);
+ background: var(--ec-token-option-checked-bg);
+}
+
+.ec-token-scope-option input {
+ width: var(--ec-icon-md);
+ height: var(--ec-icon-md);
+ margin: var(--ec-space-1) 0 0;
+ accent-color: var(--ec-accent);
+}
+
+.ec-token-scope-copy {
+ display: flex;
+ flex-direction: column;
+ gap: calc(var(--ec-space-1) / 2);
+}
+
+.ec-token-scope-title {
+ font-size: var(--ec-text-md);
+ font-weight: var(--ec-weight-semibold);
+}
+
+.ec-token-scope-hint {
+ font-size: var(--ec-text-sm);
+ color: var(--ec-text-muted);
+}
+
+.ec-segmented-inline {
+ display: inline-flex;
+ align-self: flex-start;
+}
+
+.ec-segmented-inline .ec-segment {
+ flex: 0 0 auto;
+}
+
+.ec-token-secret {
+ display: flex;
+ gap: var(--ec-space-3);
+}
+
+.ec-token-secret .ec-input {
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
+.ec-token-secret .ec-button {
+ flex-shrink: 0;
+}
+
.ec-button-quiet {
background: transparent;
color: var(--ec-text-soft);
@@ -4392,6 +4559,7 @@ html.ec-scroll-locked {
height: var(--ec-touch-min);
border: 1px solid var(--ec-border);
border-radius: var(--ec-radius-pill);
+ white-space: nowrap;
background: var(--ec-surface);
}
@@ -4409,6 +4577,33 @@ html.ec-scroll-locked {
min-height: var(--ec-touch-min);
}
+ .ec-settings-nav-item .ec-icon,
+ .ec-token-cell-wide,
+ .ec-token-subtitle-long {
+ display: none;
+ }
+
+ .ec-token-subtitle-short {
+ display: inline;
+ }
+
+ .ec-token-meta {
+ display: flex;
+ }
+
+ .ec-token-meta .ec-token-chip {
+ height: var(--ec-token-chip-sm-height);
+ padding: 0 var(--ec-space-2);
+ }
+
+ .ec-token-meta .ec-token-expired::before {
+ display: none;
+ }
+
+ .ec-token-row .ec-settings-row-actions .ec-button {
+ height: var(--ec-touch-min);
+ }
+
.ec-settings-layout .ec-card-heading {
flex: 1 1 auto;
min-width: 0;
diff --git a/src/App/EvilCase.App/wwwroot/css/ec-tokens.css b/src/App/EvilCase.App/wwwroot/css/ec-tokens.css
index ac6538c6..8fe9d17e 100644
--- a/src/App/EvilCase.App/wwwroot/css/ec-tokens.css
+++ b/src/App/EvilCase.App/wwwroot/css/ec-tokens.css
@@ -257,6 +257,26 @@
--ec-settings-col-color: 150px;
--ec-settings-col-count: 90px;
--ec-settings-col-actions: 96px;
+ --ec-settings-nav-gap: 10px;
+
+ /* API tokeny (docs/design/nastaveni-api-tokeny.html) */
+ --ec-token-col-scope: 140px;
+ --ec-token-col-created: 120px;
+ --ec-token-col-expires: 130px;
+ --ec-token-col-used: 160px;
+ --ec-token-col-actions: 88px;
+ --ec-token-row-height: 52px;
+ --ec-token-chip-height: 24px;
+ --ec-token-chip-sm-height: 20px;
+ --ec-token-option-pad: 14px;
+ --ec-token-read-bg: #EEF0F3;
+ --ec-token-read-text: var(--ec-text-soft);
+ --ec-token-write-bg: var(--ec-accent-soft);
+ --ec-token-write-text: #2A48B8;
+ --ec-token-expired-bg: #FDE4E1;
+ --ec-token-expired-text: var(--ec-danger-text);
+ --ec-token-unused-text: #9AA0AB;
+ --ec-token-option-checked-bg: #F5F7FD;
/* Detail spisu (docs/design/detail-spisu.html) */
--ec-case-act-col-date: 96px;
diff --git a/src/Business/EvilCase.Domain/ApiTokens/ApiTokenScope.cs b/src/Business/EvilCase.Domain/ApiTokens/ApiTokenScope.cs
new file mode 100644
index 00000000..15fe9027
--- /dev/null
+++ b/src/Business/EvilCase.Domain/ApiTokens/ApiTokenScope.cs
@@ -0,0 +1,12 @@
+using System.Text.Json.Serialization;
+using EvilBrains.EvilCase.Domain.Json;
+
+namespace EvilBrains.EvilCase.Domain.ApiTokens;
+
+[JsonConverter(typeof(StrictJsonStringEnumConverter))]
+public enum ApiTokenScope
+{
+ ReadOnly = 0,
+
+ ReadWrite = 1,
+}
diff --git a/src/Common/EvilCase.Auth/ApiTokenAuthenticationHandler.cs b/src/Common/EvilCase.Auth/ApiTokenAuthenticationHandler.cs
new file mode 100644
index 00000000..bbaa434d
--- /dev/null
+++ b/src/Common/EvilCase.Auth/ApiTokenAuthenticationHandler.cs
@@ -0,0 +1,53 @@
+using System.Security.Claims;
+using System.Text.Encodings.Web;
+using EvilBrains.EvilCase.Api.Contract.User;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace EvilBrains.EvilCase.Auth;
+
+///
+/// Reads the token and its user from the database on every request, so a revocation holds from the
+/// next request and tenant and role follow the user row. A lockout does not stop it.
+///
+internal sealed class ApiTokenAuthenticationHandler(
+ IOptionsMonitor options,
+ ILoggerFactory loggerFactory,
+ UrlEncoder encoder,
+ IApiTokenStore apiTokenStore,
+ TimeProvider timeProvider) : AuthenticationHandler(options, loggerFactory, encoder)
+{
+ public const string SchemeName = "ApiToken";
+
+ private static readonly TimeSpan LastUsedResolution = TimeSpan.FromMinutes(1);
+
+ protected override async Task HandleAuthenticateAsync()
+ {
+ if (ApiTokenValue.Read(this.Request) is not { } presented)
+ return AuthenticateResult.NoResult();
+
+ var now = timeProvider.GetUtcNow().UtcDateTime;
+ var stored = await apiTokenStore.FindApiToken(ApiTokenValue.Hash(presented), this.Context.RequestAborted);
+
+ if (stored?.User is not { } user || stored.Expires <= now)
+ return AuthenticateResult.Fail("The API token is unknown, revoked or expired.");
+
+ await apiTokenStore.TouchApiToken(stored.Id, now, now - LastUsedResolution, this.Context.RequestAborted);
+
+ Claim[] claims =
+ [
+ new(AuthClaims.Subject, user.Id.ToString("D", CultureInfo.InvariantCulture)),
+ new(AuthClaims.Email, user.Email),
+ new(AuthClaims.Role, user.Role.ToString()),
+ new(AuthClaims.Tenant, user.TenantId.ToString("D", CultureInfo.InvariantCulture)),
+ new(AuthClaims.ApiTokenId, stored.Id.ToString("D", CultureInfo.InvariantCulture)),
+ new(AuthClaims.ApiTokenName, stored.Name),
+ new(AuthClaims.ApiTokenScope, stored.Scope.ToString()),
+ ];
+
+ var identity = new ClaimsIdentity(claims, this.Scheme.Name, AuthClaims.Email, AuthClaims.Role);
+
+ return AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), this.Scheme.Name));
+ }
+}
diff --git a/src/Common/EvilCase.Auth/ApiTokenBoundaryMiddleware.cs b/src/Common/EvilCase.Auth/ApiTokenBoundaryMiddleware.cs
new file mode 100644
index 00000000..ca56b8cf
--- /dev/null
+++ b/src/Common/EvilCase.Auth/ApiTokenBoundaryMiddleware.cs
@@ -0,0 +1,71 @@
+using System.Security.Claims;
+using EvilBrains.EvilCase.Api.Contract.ApiTokens;
+using EvilBrains.EvilCase.Api.Contract.User;
+using EvilBrains.EvilCase.Domain.ApiTokens;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace EvilBrains.EvilCase.Auth;
+
+///
+/// Runs after authentication on every path, anonymous endpoints included: a token never reaches sign-in or
+/// its own management, and a read-only token never writes. Everything a token's request logs carries the
+/// token's id and name.
+///
+internal sealed class ApiTokenBoundaryMiddleware(RequestDelegate next, ILogger logger)
+{
+ public async Task Invoke(HttpContext context)
+ {
+ var user = context.User;
+
+ if (user.FindFirstValue(AuthClaims.ApiTokenId) is not { } apiTokenId)
+ {
+ await next(context);
+ return;
+ }
+
+ using var scope = logger.BeginScope(new Dictionary(StringComparer.Ordinal)
+ {
+ ["ApiTokenId"] = apiTokenId,
+ ["ApiTokenName"] = user.FindFirstValue(AuthClaims.ApiTokenName),
+ });
+
+ if (Refusal(context.Request, user) is not { } detail)
+ {
+ await next(context);
+ return;
+ }
+
+ context.Response.StatusCode = StatusCodes.Status403Forbidden;
+
+ var problemDetails = context.RequestServices.GetRequiredService();
+
+ await problemDetails.TryWriteAsync(new()
+ {
+ HttpContext = context,
+ ProblemDetails =
+ {
+ Status = StatusCodes.Status403Forbidden,
+ Title = "Forbidden",
+ Detail = detail,
+ },
+ });
+ }
+
+ private static string? Refusal(HttpRequest request, ClaimsPrincipal user)
+ {
+ if (request.Path.StartsWithSegments(AuthRoute.Path, StringComparison.OrdinalIgnoreCase)
+ || request.Path.StartsWithSegments(ApiTokenRoute.Path, StringComparison.OrdinalIgnoreCase))
+ {
+ return "An API token cannot reach sign-in, sessions or API token management.";
+ }
+
+ var write = !HttpMethods.IsGet(request.Method) && !HttpMethods.IsHead(request.Method) && !HttpMethods.IsOptions(request.Method);
+
+ if (write && !string.Equals(user.FindFirstValue(AuthClaims.ApiTokenScope), nameof(ApiTokenScope.ReadWrite), StringComparison.Ordinal))
+ return "A read-only API token cannot write.";
+
+ return null;
+ }
+}
diff --git a/src/Common/EvilCase.Auth/ApiTokenCreateOutcome.cs b/src/Common/EvilCase.Auth/ApiTokenCreateOutcome.cs
new file mode 100644
index 00000000..18626e44
--- /dev/null
+++ b/src/Common/EvilCase.Auth/ApiTokenCreateOutcome.cs
@@ -0,0 +1,8 @@
+namespace EvilBrains.EvilCase.Auth;
+
+public enum ApiTokenCreateOutcome
+{
+ Created = 0,
+
+ LimitReached = 1,
+}
diff --git a/src/Common/EvilCase.Auth/ApiTokenCreateResult.cs b/src/Common/EvilCase.Auth/ApiTokenCreateResult.cs
new file mode 100644
index 00000000..16ca1a32
--- /dev/null
+++ b/src/Common/EvilCase.Auth/ApiTokenCreateResult.cs
@@ -0,0 +1,10 @@
+using EvilBrains.EvilCase.Api.Contract.ApiTokens;
+
+namespace EvilBrains.EvilCase.Auth;
+
+public sealed record ApiTokenCreateResult
+{
+ public required ApiTokenCreateOutcome Outcome { get; init; }
+
+ public ApiTokenCreated? Created { get; init; }
+}
diff --git a/src/Common/EvilCase.Auth/ApiTokenRevokeOutcome.cs b/src/Common/EvilCase.Auth/ApiTokenRevokeOutcome.cs
new file mode 100644
index 00000000..28f9d9b2
--- /dev/null
+++ b/src/Common/EvilCase.Auth/ApiTokenRevokeOutcome.cs
@@ -0,0 +1,8 @@
+namespace EvilBrains.EvilCase.Auth;
+
+public enum ApiTokenRevokeOutcome
+{
+ Revoked = 0,
+
+ NotFound = 1,
+}
diff --git a/src/Common/EvilCase.Auth/ApiTokenService.cs b/src/Common/EvilCase.Auth/ApiTokenService.cs
new file mode 100644
index 00000000..a592effa
--- /dev/null
+++ b/src/Common/EvilCase.Auth/ApiTokenService.cs
@@ -0,0 +1,64 @@
+using EvilBrains.EvilCase.Api.Contract.ApiTokens;
+using EvilBrains.EvilCase.Data.Entities;
+using Microsoft.Extensions.Logging;
+
+namespace EvilBrains.EvilCase.Auth;
+
+internal sealed class ApiTokenService(IApiTokenStore apiTokenStore, TimeProvider timeProvider, ILogger logger) : IApiTokenService
+{
+ public async Task> ListApiTokens(Guid userId, CancellationToken token)
+ {
+ var apiTokens = await apiTokenStore.ListApiTokens(userId, token);
+
+ return [.. apiTokens.Select(Describe)];
+ }
+
+ public async Task CreateApiToken(Guid userId, ApiTokenCreateRequest request, CancellationToken token)
+ {
+ var now = timeProvider.GetUtcNow().UtcDateTime;
+ var secret = ApiTokenValue.Create();
+
+ var apiToken = new ApiToken
+ {
+ UserId = userId,
+ Name = request.Name.Trim(),
+ Scope = request.Scope,
+ TokenHash = ApiTokenValue.Hash(secret),
+ Expires = now.AddDays(request.ExpiresInDays),
+ };
+
+ if (!await apiTokenStore.AddApiToken(apiToken, now, ApiTokenLimits.MaxActive, token))
+ return new() { Outcome = ApiTokenCreateOutcome.LimitReached };
+
+ logger.LogInformation("API token {ApiTokenId} was created for user {UserId} with scope {ApiTokenScope}", apiToken.Id, userId, apiToken.Scope);
+
+ return new()
+ {
+ Outcome = ApiTokenCreateOutcome.Created,
+ Created = new() { Token = Describe(apiToken), Secret = secret },
+ };
+ }
+
+ public async Task RevokeApiToken(Guid userId, Guid apiTokenId, CancellationToken token)
+ {
+ if (!await apiTokenStore.DeleteApiToken(userId, apiTokenId, token))
+ return ApiTokenRevokeOutcome.NotFound;
+
+ logger.LogInformation("API token {ApiTokenId} of user {UserId} was revoked", apiTokenId, userId);
+
+ return ApiTokenRevokeOutcome.Revoked;
+ }
+
+ private static ApiTokenItem Describe(ApiToken apiToken)
+ {
+ return new()
+ {
+ ApiTokenId = apiToken.Id,
+ Name = apiToken.Name,
+ Scope = apiToken.Scope,
+ Created = apiToken.Created,
+ Expires = apiToken.Expires,
+ LastUsed = apiToken.LastUsed,
+ };
+ }
+}
diff --git a/src/Common/EvilCase.Auth/ApiTokenStore.cs b/src/Common/EvilCase.Auth/ApiTokenStore.cs
new file mode 100644
index 00000000..110c2037
--- /dev/null
+++ b/src/Common/EvilCase.Auth/ApiTokenStore.cs
@@ -0,0 +1,71 @@
+using EvilBrains.EvilCase.Data.DbContexts;
+using EvilBrains.EvilCase.Data.Entities;
+using Microsoft.EntityFrameworkCore;
+
+namespace EvilBrains.EvilCase.Auth;
+
+internal sealed class ApiTokenStore(IDbSession dbSession) : IApiTokenStore
+{
+ public async Task AddApiToken(ApiToken apiToken, DateTime now, int maxActive, CancellationToken token)
+ {
+ var context = dbSession.Current;
+
+ await using var transaction = await dbSession.BeginTransaction(token);
+
+ // The user row's lock settles two creations at once: the second counts after the first has written.
+ await context.Users
+ .FromSql($"""SELECT * FROM "Users" WHERE "Id" = {apiToken.UserId} FOR UPDATE""")
+ .IgnoreQueryFilters()
+ .Select(static user => user.Id)
+ .SingleAsync(token);
+
+ var active = await context.ApiTokens
+ .Where(stored => stored.UserId == apiToken.UserId)
+ .Where(stored => stored.Expires > now)
+ .CountAsync(token);
+
+ if (active >= maxActive)
+ return false;
+
+ context.ApiTokens.Add(apiToken);
+ await context.SaveChangesAsync(token);
+
+ await transaction.CommitAsync(token);
+
+ return true;
+ }
+
+ public async Task> ListApiTokens(Guid userId, CancellationToken token)
+ {
+ return await dbSession.Current.ApiTokens
+ .Where(stored => stored.UserId == userId)
+ .OrderByDescending(static stored => stored.Created)
+ .ToListAsync(token);
+ }
+
+ // The user carries the tenant filter, and no tenant is known yet.
+ public async Task FindApiToken(string tokenHash, CancellationToken token)
+ {
+ return await dbSession.Current.ApiTokens
+ .IgnoreQueryFilters()
+ .Include(static stored => stored.User)
+ .SingleOrDefaultAsync(stored => stored.TokenHash == tokenHash, token);
+ }
+
+ public async Task DeleteApiToken(Guid userId, Guid apiTokenId, CancellationToken token)
+ {
+ return await dbSession.Current.ApiTokens
+ .Where(stored => stored.Id == apiTokenId)
+ .Where(stored => stored.UserId == userId)
+ .ExecuteDeleteAsync(token) > 0;
+ }
+
+ // The condition sits in the statement, so concurrent requests write at most once per window.
+ public async Task TouchApiToken(Guid apiTokenId, DateTime now, DateTime staleBefore, CancellationToken token)
+ {
+ await dbSession.Current.ApiTokens
+ .Where(stored => stored.Id == apiTokenId)
+ .Where(stored => stored.LastUsed == null || stored.LastUsed < staleBefore)
+ .ExecuteUpdateAsync(setters => setters.SetProperty(static stored => stored.LastUsed, now), token);
+ }
+}
diff --git a/src/Common/EvilCase.Auth/ApiTokenValue.cs b/src/Common/EvilCase.Auth/ApiTokenValue.cs
new file mode 100644
index 00000000..b84321e2
--- /dev/null
+++ b/src/Common/EvilCase.Auth/ApiTokenValue.cs
@@ -0,0 +1,46 @@
+using System.Buffers.Text;
+using System.Net.Http.Headers;
+using System.Security.Cryptography;
+using System.Text;
+using Microsoft.AspNetCore.Http;
+
+namespace EvilBrains.EvilCase.Auth;
+
+///
+/// SHA-256, not a KDF, for the same reason as . The prefix picks the
+/// authentication scheme and lets a secret scanner recognise a leaked token.
+///
+internal static class ApiTokenValue
+{
+ public const string Prefix = "evc_";
+
+ private const int SizeInBytes = 32;
+
+ public static string Create()
+ {
+ return Prefix + Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(SizeInBytes));
+ }
+
+ public static string Hash(string token)
+ {
+ return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
+ }
+
+ ///
+ /// The API token a single Authorization: Bearer header carries; any other header is the JWT
+ /// scheme's to judge.
+ ///
+ public static string? Read(HttpRequest request)
+ {
+ var headers = request.Headers.Authorization;
+
+ if (headers.Count != 1 || !AuthenticationHeaderValue.TryParse(headers[0], out var header))
+ return null;
+
+ return string.Equals(header.Scheme, "Bearer", StringComparison.OrdinalIgnoreCase)
+ && header.Parameter is { } value
+ && value.StartsWith(Prefix, StringComparison.Ordinal)
+ ? value
+ : null;
+ }
+}
diff --git a/src/Common/EvilCase.Auth/Bootstrap.cs b/src/Common/EvilCase.Auth/Bootstrap.cs
index fe125d37..0ffc7b21 100644
--- a/src/Common/EvilCase.Auth/Bootstrap.cs
+++ b/src/Common/EvilCase.Auth/Bootstrap.cs
@@ -1,8 +1,10 @@
using System.Text;
using EvilBrains.EvilCase.Api.Contract.User;
using EvilBrains.EvilCase.Data.DbContexts;
+using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
@@ -13,6 +15,8 @@ namespace EvilBrains.EvilCase.Auth;
public static class Bootstrap
{
+ private const string SelectorScheme = "EvilCase";
+
public static IServiceCollection AddEvilCaseAuth(this IServiceCollection services)
{
services
@@ -29,39 +33,25 @@ public static IServiceCollection AddEvilCaseAuth(this IServiceCollection service
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ // An API token goes to its own handler, anything else to the JWT, which also reads the access
+ // cookie; a token beside the cookie therefore wins.
services
- .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
- .AddJwtBearer();
+ .AddAuthentication(SelectorScheme)
+ .AddPolicyScheme(
+ SelectorScheme,
+ displayName: null,
+ static options => options.ForwardDefaultSelector = static context => ApiTokenValue.Read(context.Request) is null
+ ? JwtBearerDefaults.AuthenticationScheme
+ : ApiTokenAuthenticationHandler.SchemeName)
+ .AddJwtBearer()
+ .AddScheme(ApiTokenAuthenticationHandler.SchemeName, configureOptions: null);
services
.AddOptions(JwtBearerDefaults.AuthenticationScheme)
- .Configure>(static (options, authSettings) =>
- {
- var jwt = authSettings.Value.Jwt;
-
- // Off, or "sub" and "role" arrive under WS-Federation URIs.
- options.MapInboundClaims = false;
-
- options.TokenValidationParameters.ValidateIssuer = true;
- options.TokenValidationParameters.ValidateAudience = true;
- options.TokenValidationParameters.ValidateLifetime = true;
- options.TokenValidationParameters.ValidateIssuerSigningKey = true;
- options.TokenValidationParameters.ValidIssuer = jwt.Issuer;
- options.TokenValidationParameters.ValidAudience = jwt.Audience;
- options.TokenValidationParameters.IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.Key));
- options.TokenValidationParameters.ClockSkew = TimeSpan.Zero;
-
- // Without these the principal has no name and no roles at all, because mapping is off.
- options.TokenValidationParameters.NameClaimType = AuthClaims.Email;
- options.TokenValidationParameters.RoleClaimType = AuthClaims.Role;
-
- // Tokens are signed with HS256; without this a caller could pick the algorithm.
- options.TokenValidationParameters.ValidAlgorithms = [SecurityAlgorithms.HmacSha256];
-
- // The browser sends the token as a cookie; a caller with an Authorization header keeps using it.
- options.Events = new() { OnMessageReceived = ReadAccessCookie };
- });
+ .Configure>(ConfigureJwtBearer);
// Default deny: an unattributed endpoint fails closed; open ones say [AllowAnonymous].
services
@@ -71,6 +61,41 @@ public static IServiceCollection AddEvilCaseAuth(this IServiceCollection service
return services;
}
+ ///
+ /// Between authentication and authorization.
+ ///
+ public static IApplicationBuilder UseEvilCaseApiTokenBoundary(this IApplicationBuilder app)
+ {
+ return app.UseMiddleware();
+ }
+
+ private static void ConfigureJwtBearer(JwtBearerOptions options, IOptions authSettings)
+ {
+ var jwt = authSettings.Value.Jwt;
+
+ // Off, or "sub" and "role" arrive under WS-Federation URIs.
+ options.MapInboundClaims = false;
+
+ options.TokenValidationParameters.ValidateIssuer = true;
+ options.TokenValidationParameters.ValidateAudience = true;
+ options.TokenValidationParameters.ValidateLifetime = true;
+ options.TokenValidationParameters.ValidateIssuerSigningKey = true;
+ options.TokenValidationParameters.ValidIssuer = jwt.Issuer;
+ options.TokenValidationParameters.ValidAudience = jwt.Audience;
+ options.TokenValidationParameters.IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.Key));
+ options.TokenValidationParameters.ClockSkew = TimeSpan.Zero;
+
+ // Without these the principal has no name and no roles at all, because mapping is off.
+ options.TokenValidationParameters.NameClaimType = AuthClaims.Email;
+ options.TokenValidationParameters.RoleClaimType = AuthClaims.Role;
+
+ // Tokens are signed with HS256; without this a caller could pick the algorithm.
+ options.TokenValidationParameters.ValidAlgorithms = [SecurityAlgorithms.HmacSha256];
+
+ // The browser sends the token as a cookie; a caller with an Authorization header keeps using it.
+ options.Events = new() { OnMessageReceived = ReadAccessCookie };
+ }
+
private static Task ReadAccessCookie(MessageReceivedContext context)
{
if (string.IsNullOrEmpty(context.Request.Headers.Authorization)
diff --git a/src/Common/EvilCase.Auth/IApiTokenService.cs b/src/Common/EvilCase.Auth/IApiTokenService.cs
new file mode 100644
index 00000000..749327cb
--- /dev/null
+++ b/src/Common/EvilCase.Auth/IApiTokenService.cs
@@ -0,0 +1,12 @@
+using EvilBrains.EvilCase.Api.Contract.ApiTokens;
+
+namespace EvilBrains.EvilCase.Auth;
+
+public interface IApiTokenService
+{
+ public Task> ListApiTokens(Guid userId, CancellationToken token);
+
+ public Task CreateApiToken(Guid userId, ApiTokenCreateRequest request, CancellationToken token);
+
+ public Task RevokeApiToken(Guid userId, Guid apiTokenId, CancellationToken token);
+}
diff --git a/src/Common/EvilCase.Auth/IApiTokenStore.cs b/src/Common/EvilCase.Auth/IApiTokenStore.cs
new file mode 100644
index 00000000..708be46b
--- /dev/null
+++ b/src/Common/EvilCase.Auth/IApiTokenStore.cs
@@ -0,0 +1,32 @@
+using EvilBrains.EvilCase.Data.Entities;
+
+namespace EvilBrains.EvilCase.Auth;
+
+internal interface IApiTokenStore
+{
+ ///
+ /// False where the user already holds tokens that have not expired at
+ /// ; nothing is written then.
+ ///
+ public Task AddApiToken(ApiToken apiToken, DateTime now, int maxActive, CancellationToken token);
+
+ ///
+ /// Newest first, expired ones included.
+ ///
+ public Task> ListApiTokens(Guid userId, CancellationToken token);
+
+ ///
+ /// With its user, whatever the tenant: a token is presented before a tenant is known.
+ ///
+ public Task FindApiToken(string tokenHash, CancellationToken token);
+
+ ///
+ /// False where the user holds no such token.
+ ///
+ public Task DeleteApiToken(Guid userId, Guid apiTokenId, CancellationToken token);
+
+ ///
+ /// Stamps as the last use unless a use after is already stamped.
+ ///
+ public Task TouchApiToken(Guid apiTokenId, DateTime now, DateTime staleBefore, CancellationToken token);
+}
diff --git a/src/Data/EvilCase.Data.Migrations/Migrations/20261030183953_ApiTokens.Designer.cs b/src/Data/EvilCase.Data.Migrations/Migrations/20261030183953_ApiTokens.Designer.cs
new file mode 100644
index 00000000..ad909e37
--- /dev/null
+++ b/src/Data/EvilCase.Data.Migrations/Migrations/20261030183953_ApiTokens.Designer.cs
@@ -0,0 +1,914 @@
+//
+using System;
+using EvilBrains.EvilCase.Data.DbContexts;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace EvilBrains.EvilCase.Data.Migrations.Migrations
+{
+ [DbContext(typeof(ApplicationDbContext))]
+ [Migration("20261030183953_ApiTokens")]
+ partial class ApiTokens
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.14")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
+ NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "unaccent");
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Account", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("Created")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("Updated")
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.ToTable("Accounts");
+ });
+
+ modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Act", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("ActNumber")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("CaseId")
+ .HasColumnType("uuid");
+
+ b.Property("ContactId")
+ .HasColumnType("uuid");
+
+ b.Property("Created")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Date")
+ .HasColumnType("date");
+
+ b.Property("Description")
+ .HasColumnType("text");
+
+ b.Property("Direction")
+ .HasMaxLength(8)
+ .HasColumnType("character varying(8)");
+
+ b.Property("ExternalActNumber")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("Updated")
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CaseId");
+
+ b.HasIndex("ContactId");
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("TenantId", "ActNumber")
+ .IsUnique();
+
+ b.ToTable("Acts", t =>
+ {
+ t.HasCheckConstraint("CK_Acts_DirectionWithContact", "(\"Direction\" IS NULL) = (\"ContactId\" IS NULL)");
+ });
+ });
+
+ modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.ApiToken", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("Created")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Expires")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastUsed")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("Scope")
+ .IsRequired()
+ .HasMaxLength(9)
+ .HasColumnType("character varying(9)");
+
+ b.Property("TokenHash")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("Updated")
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TokenHash")
+ .IsUnique();
+
+ b.HasIndex("UserId");
+
+ b.ToTable("ApiTokens");
+ });
+
+ modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Case", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("CaseNumber")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("ContactId")
+ .HasColumnType("uuid");
+
+ b.Property("Created")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Date")
+ .HasColumnType("date");
+
+ b.Property("Description")
+ .HasColumnType("text");
+
+ b.Property("ExternalCaseNumber")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("ParentCaseId")
+ .HasColumnType("uuid");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(18)
+ .HasColumnType("character varying(18)");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("Updated")
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ContactId");
+
+ b.HasIndex("ParentCaseId");
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("TenantId", "CaseNumber")
+ .IsUnique();
+
+ b.ToTable("Cases");
+ });
+
+ modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.CaseAncestor", b =>
+ {
+ b.Property("CaseId")
+ .HasColumnType("uuid");
+
+ b.Property("Depth")
+ .HasColumnType("integer");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.HasKey("CaseId");
+
+ b.ToTable((string)null);
+ });
+
+ modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Comment", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("ActId")
+ .HasColumnType("uuid");
+
+ b.Property("Body")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("CaseId")
+ .HasColumnType("uuid");
+
+ b.Property("Created")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid");
+
+ b.Property("Updated")
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ActId");
+
+ b.HasIndex("CaseId");
+
+ b.HasIndex("TenantId");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("Comments", t =>
+ {
+ t.HasCheckConstraint("CK_Comments_OnACaseOrAnAct", "(\"CaseId\" IS NULL) <> (\"ActId\" IS NULL)");
+ });
+ });
+
+ modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Contact", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("Address")
+ .HasMaxLength(1024)
+ .HasColumnType("character varying(1024)");
+
+ b.Property("Created")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DataBoxId")
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)");
+
+ b.Property("Kind")
+ .IsRequired()
+ .HasMaxLength(9)
+ .HasColumnType("character varying(9)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid");
+
+ b.Property("Updated")
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId");
+
+ b.ToTable("Contacts");
+ });
+
+ modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.FileAsset", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("ActId")
+ .HasColumnType("uuid");
+
+ b.Property("CaseId")
+ .HasColumnType("uuid");
+
+ b.Property("Created")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("FileName")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid");
+
+ b.Property("Updated")
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ActId");
+
+ b.HasIndex("CaseId");
+
+ b.HasIndex("TenantId");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("FileAssets", t =>
+ {
+ t.HasCheckConstraint("CK_FileAssets_OnACaseOrAnAct", "(\"CaseId\" IS NULL) <> (\"ActId\" IS NULL)");
+ });
+ });
+
+ modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.FileVersion", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("ContentHash")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("Created")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("FileAssetId")
+ .HasColumnType("uuid");
+
+ b.Property("MediaType")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("Number")
+ .HasColumnType("integer");
+
+ b.Property("SizeBytes")
+ .HasColumnType("bigint");
+
+ b.Property("StoragePath")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid");
+
+ b.Property("Updated")
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("FileAssetId");
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("TenantId", "FileAssetId", "Number")
+ .IsUnique();
+
+ b.ToTable("FileVersions");
+ });
+
+ modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Label", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("Color")
+ .IsRequired()
+ .HasMaxLength(6)
+ .HasColumnType("character varying(6)");
+
+ b.Property("Created")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid");
+
+ b.Property("Updated")
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId", "Name")
+ .IsUnique();
+
+ b.ToTable("Labels");
+ });
+
+ modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.LabelAssignment", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("ActId")
+ .HasColumnType("uuid");
+
+ b.Property("CaseId")
+ .HasColumnType("uuid");
+
+ b.Property("Created")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LabelId")
+ .HasColumnType("uuid");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid");
+
+ b.Property("Updated")
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ActId");
+
+ b.HasIndex("CaseId");
+
+ b.HasIndex("LabelId");
+
+ b.HasIndex("TenantId", "LabelId", "ActId")
+ .IsUnique();
+
+ b.HasIndex("TenantId", "LabelId", "CaseId")
+ .IsUnique();
+
+ b.ToTable("LabelAssignments", t =>
+ {
+ t.HasCheckConstraint("CK_LabelAssignments_OnACaseOrAnAct", "(\"CaseId\" IS NULL) <> (\"ActId\" IS NULL)");
+ });
+ });
+
+ modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.RefreshToken", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("AuthSessionId")
+ .HasColumnType("uuid");
+
+ b.Property("Created")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedByIp")
+ .HasMaxLength(45)
+ .HasColumnType("character varying(45)");
+
+ b.Property("Expires")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastUsed")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("RevokedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("SessionExpires")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("TokenHash")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("Updated")
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserAgent")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AuthSessionId");
+
+ b.HasIndex("TokenHash")
+ .IsUnique();
+
+ b.HasIndex("UserId");
+
+ b.ToTable("RefreshTokens");
+ });
+
+ modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Tenant", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("AccountId")
+ .HasColumnType("uuid");
+
+ b.Property("Created")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("Updated")
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AccountId");
+
+ b.ToTable("Tenants");
+ });
+
+ modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.User", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("Created")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("FailedLoginAttempts")
+ .HasColumnType("integer");
+
+ b.Property("LockoutEnd")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("Role")
+ .IsRequired()
+ .HasMaxLength(5)
+ .HasColumnType("character varying(5)");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid");
+
+ b.Property("Updated")
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Email")
+ .IsUnique();
+
+ b.HasIndex("TenantId");
+
+ b.ToTable("Users");
+ });
+
+ modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Act", b =>
+ {
+ b.HasOne("EvilBrains.EvilCase.Data.Entities.Case", "Case")
+ .WithMany("Acts")
+ .HasForeignKey("CaseId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("EvilBrains.EvilCase.Data.Entities.Contact", "Contact")
+ .WithMany("Acts")
+ .HasForeignKey("ContactId")
+ .OnDelete(DeleteBehavior.Restrict);
+
+ b.HasOne("EvilBrains.EvilCase.Data.Entities.Tenant", null)
+ .WithMany()
+ .HasForeignKey("TenantId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("EvilBrains.EvilCase.Data.Entities.User", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Case");
+
+ b.Navigation("Contact");
+ });
+
+ modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.ApiToken", b =>
+ {
+ b.HasOne("EvilBrains.EvilCase.Data.Entities.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Case", b =>
+ {
+ b.HasOne("EvilBrains.EvilCase.Data.Entities.Contact", "Contact")
+ .WithMany("Cases")
+ .HasForeignKey("ContactId")
+ .OnDelete(DeleteBehavior.Restrict);
+
+ b.HasOne("EvilBrains.EvilCase.Data.Entities.Case", "ParentCase")
+ .WithMany("ChildCases")
+ .HasForeignKey("ParentCaseId")
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.HasOne("EvilBrains.EvilCase.Data.Entities.Tenant", null)
+ .WithMany()
+ .HasForeignKey("TenantId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("EvilBrains.EvilCase.Data.Entities.User", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Contact");
+
+ b.Navigation("ParentCase");
+ });
+
+ modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Comment", b =>
+ {
+ b.HasOne("EvilBrains.EvilCase.Data.Entities.Act", "Act")
+ .WithMany("Comments")
+ .HasForeignKey("ActId")
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.HasOne("EvilBrains.EvilCase.Data.Entities.Case", "Case")
+ .WithMany("Comments")
+ .HasForeignKey("CaseId")
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.HasOne("EvilBrains.EvilCase.Data.Entities.Tenant", null)
+ .WithMany()
+ .HasForeignKey("TenantId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("EvilBrains.EvilCase.Data.Entities.User", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Act");
+
+ b.Navigation("Case");
+ });
+
+ modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Contact", b =>
+ {
+ b.HasOne("EvilBrains.EvilCase.Data.Entities.Tenant", null)
+ .WithMany()
+ .HasForeignKey("TenantId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.FileAsset", b =>
+ {
+ b.HasOne("EvilBrains.EvilCase.Data.Entities.Act", "Act")
+ .WithMany("Files")
+ .HasForeignKey("ActId")
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.HasOne("EvilBrains.EvilCase.Data.Entities.Case", "Case")
+ .WithMany("Files")
+ .HasForeignKey("CaseId")
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.HasOne("EvilBrains.EvilCase.Data.Entities.Tenant", null)
+ .WithMany()
+ .HasForeignKey("TenantId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("EvilBrains.EvilCase.Data.Entities.User", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Act");
+
+ b.Navigation("Case");
+ });
+
+ modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.FileVersion", b =>
+ {
+ b.HasOne("EvilBrains.EvilCase.Data.Entities.FileAsset", "FileAsset")
+ .WithMany("Versions")
+ .HasForeignKey("FileAssetId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("EvilBrains.EvilCase.Data.Entities.Tenant", null)
+ .WithMany()
+ .HasForeignKey("TenantId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("EvilBrains.EvilCase.Data.Entities.User", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("FileAsset");
+ });
+
+ modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Label", b =>
+ {
+ b.HasOne("EvilBrains.EvilCase.Data.Entities.Tenant", null)
+ .WithMany()
+ .HasForeignKey("TenantId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.LabelAssignment", b =>
+ {
+ b.HasOne("EvilBrains.EvilCase.Data.Entities.Act", "Act")
+ .WithMany("Labels")
+ .HasForeignKey("ActId")
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.HasOne("EvilBrains.EvilCase.Data.Entities.Case", "Case")
+ .WithMany("Labels")
+ .HasForeignKey("CaseId")
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.HasOne("EvilBrains.EvilCase.Data.Entities.Label", "Label")
+ .WithMany("Assignments")
+ .HasForeignKey("LabelId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("EvilBrains.EvilCase.Data.Entities.Tenant", null)
+ .WithMany()
+ .HasForeignKey("TenantId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Act");
+
+ b.Navigation("Case");
+
+ b.Navigation("Label");
+ });
+
+ modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.RefreshToken", b =>
+ {
+ b.HasOne("EvilBrains.EvilCase.Data.Entities.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Tenant", b =>
+ {
+ b.HasOne("EvilBrains.EvilCase.Data.Entities.Account", null)
+ .WithMany()
+ .HasForeignKey("AccountId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.User", b =>
+ {
+ b.HasOne("EvilBrains.EvilCase.Data.Entities.Tenant", null)
+ .WithMany()
+ .HasForeignKey("TenantId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Act", b =>
+ {
+ b.Navigation("Comments");
+
+ b.Navigation("Files");
+
+ b.Navigation("Labels");
+ });
+
+ modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Case", b =>
+ {
+ b.Navigation("Acts");
+
+ b.Navigation("ChildCases");
+
+ b.Navigation("Comments");
+
+ b.Navigation("Files");
+
+ b.Navigation("Labels");
+ });
+
+ modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Contact", b =>
+ {
+ b.Navigation("Acts");
+
+ b.Navigation("Cases");
+ });
+
+ modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.FileAsset", b =>
+ {
+ b.Navigation("Versions");
+ });
+
+ modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Label", b =>
+ {
+ b.Navigation("Assignments");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Data/EvilCase.Data.Migrations/Migrations/20261030183953_ApiTokens.cs b/src/Data/EvilCase.Data.Migrations/Migrations/20261030183953_ApiTokens.cs
new file mode 100644
index 00000000..92909fa7
--- /dev/null
+++ b/src/Data/EvilCase.Data.Migrations/Migrations/20261030183953_ApiTokens.cs
@@ -0,0 +1,56 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace EvilBrains.EvilCase.Data.Migrations.Migrations;
+
+// Outside the history like RefreshTokens: the rows carry the token hash.
+public partial class ApiTokens : Migration
+{
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "ApiTokens",
+ columns: static table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ UserId = table.Column(type: "uuid", nullable: false),
+ Name = table.Column(type: "character varying(64)", maxLength: 64, nullable: false),
+ Scope = table.Column(type: "character varying(9)", maxLength: 9, nullable: false),
+ TokenHash = table.Column(type: "character varying(64)", maxLength: 64, nullable: false),
+ Created = table.Column(type: "timestamp with time zone", nullable: false),
+ Updated = table.Column(type: "timestamp with time zone", nullable: true),
+ Expires = table.Column(type: "timestamp with time zone", nullable: false),
+ LastUsed = table.Column(type: "timestamp with time zone", nullable: true),
+ },
+ constraints: static table =>
+ {
+ table.PrimaryKey("PK_ApiTokens", static x => x.Id);
+ table.ForeignKey(
+ name: "FK_ApiTokens_Users_UserId",
+ column: static x => x.UserId,
+ principalTable: "Users",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_ApiTokens_TokenHash",
+ table: "ApiTokens",
+ column: "TokenHash",
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_ApiTokens_UserId",
+ table: "ApiTokens",
+ column: "UserId");
+
+ migrationBuilder.Sql(
+ """CREATE TRIGGER stamp_timestamps BEFORE INSERT OR UPDATE ON "ApiTokens" FOR EACH ROW EXECUTE FUNCTION stamp_timestamps();""");
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(name: "ApiTokens");
+ }
+}
diff --git a/src/Data/EvilCase.Data.Migrations/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Data/EvilCase.Data.Migrations/Migrations/ApplicationDbContextModelSnapshot.cs
index 70a63d5c..cd8155c8 100644
--- a/src/Data/EvilCase.Data.Migrations/Migrations/ApplicationDbContextModelSnapshot.cs
+++ b/src/Data/EvilCase.Data.Migrations/Migrations/ApplicationDbContextModelSnapshot.cs
@@ -113,6 +113,53 @@ protected override void BuildModel(ModelBuilder modelBuilder)
});
});
+ modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.ApiToken", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("Created")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Expires")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastUsed")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("Scope")
+ .IsRequired()
+ .HasMaxLength(9)
+ .HasColumnType("character varying(9)");
+
+ b.Property("TokenHash")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("Updated")
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TokenHash")
+ .IsUnique();
+
+ b.HasIndex("UserId");
+
+ b.ToTable("ApiTokens");
+ });
+
modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Case", b =>
{
b.Property("Id")
@@ -621,6 +668,17 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Navigation("Contact");
});
+ modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.ApiToken", b =>
+ {
+ b.HasOne("EvilBrains.EvilCase.Data.Entities.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Case", b =>
{
b.HasOne("EvilBrains.EvilCase.Data.Entities.Contact", "Contact")
diff --git a/src/Data/EvilCase.Data/DbContexts/ApplicationDbContext.cs b/src/Data/EvilCase.Data/DbContexts/ApplicationDbContext.cs
index f6915d37..2d7ed462 100644
--- a/src/Data/EvilCase.Data/DbContexts/ApplicationDbContext.cs
+++ b/src/Data/EvilCase.Data/DbContexts/ApplicationDbContext.cs
@@ -17,6 +17,8 @@ public class ApplicationDbContext(DbContextOptions options
public DbSet RefreshTokens => this.Set();
+ public DbSet ApiTokens => this.Set();
+
public DbSet Contacts => this.Set();
public DbSet Cases => this.Set();
@@ -171,6 +173,12 @@ private static void ConfigureAccounts(ModelBuilder modelBuilder)
.WithMany()
.HasForeignKey(static token => token.UserId)
.OnDelete(DeleteBehavior.Cascade);
+
+ modelBuilder.Entity()
+ .HasOne(static token => token.User)
+ .WithMany()
+ .HasForeignKey(static token => token.UserId)
+ .OnDelete(DeleteBehavior.Cascade);
}
private static void ConfigureCases(ModelBuilder modelBuilder)
diff --git a/src/Data/EvilCase.Data/Entities/ApiToken.cs b/src/Data/EvilCase.Data/Entities/ApiToken.cs
new file mode 100644
index 00000000..b707bbbc
--- /dev/null
+++ b/src/Data/EvilCase.Data/Entities/ApiToken.cs
@@ -0,0 +1,40 @@
+using System.ComponentModel.DataAnnotations;
+using EvilBrains.EvilCase.Domain.ApiTokens;
+using Microsoft.EntityFrameworkCore;
+
+namespace EvilBrains.EvilCase.Data.Entities;
+
+///
+/// Outside the tenant like : authentication finds it before a tenant is known.
+/// Revocation deletes the row.
+///
+[Index(nameof(TokenHash), IsUnique = true)]
+[Index(nameof(UserId))]
+public sealed record ApiToken : IEntity
+{
+ [Key]
+ public Guid Id { get; init; } = Guid.CreateVersion7();
+
+ public required Guid UserId { get; init; }
+
+ [MaxLength(64)]
+ public required string Name { get; init; }
+
+ public required ApiTokenScope Scope { get; init; }
+
+ ///
+ /// SHA-256 of the whole token, hex.
+ ///
+ [MaxLength(64)]
+ public required string TokenHash { get; init; }
+
+ public DateTime Created { get; init; }
+
+ public DateTime? Updated { get; init; }
+
+ public required DateTime Expires { get; init; }
+
+ public DateTime? LastUsed { get; init; }
+
+ public User? User { get; init; }
+}
diff --git a/src/EvilCase.Host/Program.cs b/src/EvilCase.Host/Program.cs
index 09f5385e..05450495 100644
--- a/src/EvilCase.Host/Program.cs
+++ b/src/EvilCase.Host/Program.cs
@@ -1,3 +1,4 @@
+using System.Security.Claims;
using System.Threading.RateLimiting;
using DotNetEnv;
using EvilBrains.Collections;
@@ -187,6 +188,23 @@ static async (context, next) =>
// Explicit: the builder's implicit UseAuthentication runs ahead of the whole pipeline,
// before forwarded headers and request logging.
app.UseAuthentication();
+
+// The request completion event is written outside the token's log scope, so it takes the token here.
+app.Use(
+ static async (context, next) =>
+ {
+ if (context.User.FindFirstValue(AuthClaims.ApiTokenId) is { } apiTokenId)
+ {
+ var diagnosticContext = context.RequestServices.GetRequiredService();
+
+ diagnosticContext.Set("ApiTokenId", apiTokenId);
+ diagnosticContext.Set("ApiTokenName", context.User.FindFirstValue(AuthClaims.ApiTokenName));
+ }
+
+ await next(context);
+ });
+
+app.UseEvilCaseApiTokenBoundary();
app.UseAuthorization();
// Replaces UseBlazorFrameworkFiles, whose /_framework branch would terminate ahead of it.
diff --git a/src/Tests/EvilCase.Tests/Auth/ApiTokenServiceTests.cs b/src/Tests/EvilCase.Tests/Auth/ApiTokenServiceTests.cs
new file mode 100644
index 00000000..05125f00
--- /dev/null
+++ b/src/Tests/EvilCase.Tests/Auth/ApiTokenServiceTests.cs
@@ -0,0 +1,105 @@
+using System.Security.Cryptography;
+using System.Text;
+using EvilBrains.EvilCase.Api.Contract.ApiTokens;
+using EvilBrains.EvilCase.Auth;
+using EvilBrains.EvilCase.Data.Entities;
+using EvilBrains.EvilCase.Domain.ApiTokens;
+using EvilBrains.EvilCase.Tests.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace EvilBrains.EvilCase.Tests.Auth;
+
+public class ApiTokenServiceTests : TenantFixture
+{
+ private static readonly ApiTokenCreateRequest Request = new() { Name = " Claude Code ", Scope = ApiTokenScope.ReadWrite, ExpiresInDays = 30 };
+
+ private ApiTokenService service = null!;
+
+ [SetUp]
+ public void SetUpService()
+ {
+ var store = new ApiTokenStore(new TransactionalDbSession(this.Tenant.Context));
+
+ this.service = new ApiTokenService(store, TimeProvider.System, NullLogger.Instance);
+ }
+
+ [Test]
+ public async Task TheSecretTravelsOnceAndOnlyItsHashIsStored()
+ {
+ var result = await this.service.CreateApiToken(this.Tenant.UserId, Request, CancellationToken.None);
+ var secret = result.Created!.Secret;
+
+ var stored = await this.Tenant.Context.ApiTokens
+ .AsNoTracking()
+ .SingleAsync(apiToken => apiToken.Id == result.Created.Token.ApiTokenId);
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(secret, Does.Match("^evc_[A-Za-z0-9_-]{43}$"), "a fixed prefix and 256 random bits, base64url");
+ Assert.That(stored.TokenHash, Is.EqualTo(Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(secret)))));
+ Assert.That(stored.Name, Is.EqualTo("Claude Code"));
+ Assert.That(stored.Expires, Is.EqualTo(DateTime.UtcNow.AddDays(30)).Within(TimeSpan.FromMinutes(1)));
+ }
+ }
+
+ [Test]
+ public async Task TheTokenPastTheLimitIsRefused()
+ {
+ for (var index = 0; index < ApiTokenLimits.MaxActive; index++)
+ await this.service.CreateApiToken(this.Tenant.UserId, Request, CancellationToken.None);
+
+ var past = await this.service.CreateApiToken(this.Tenant.UserId, Request, CancellationToken.None);
+ var count = await this.Tenant.Context.ApiTokens.CountAsync(apiToken => apiToken.UserId == this.Tenant.UserId);
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(past.Outcome, Is.EqualTo(ApiTokenCreateOutcome.LimitReached));
+ Assert.That(count, Is.EqualTo(ApiTokenLimits.MaxActive), "the refused token writes nothing");
+ }
+ }
+
+ [Test]
+ public async Task AnExpiredTokenLeavesRoomUnderTheLimit()
+ {
+ for (var index = 0; index < ApiTokenLimits.MaxActive; index++)
+ {
+ this.Tenant.Context.ApiTokens.Add(new ApiToken
+ {
+ UserId = this.Tenant.UserId,
+ Name = "expired",
+ Scope = ApiTokenScope.ReadOnly,
+ TokenHash = ApiTokenValue.Hash(ApiTokenValue.Create()),
+ Expires = DateTime.UtcNow.AddMinutes(-1),
+ });
+ }
+
+ await this.Tenant.Context.SaveChangesAsync();
+
+ var result = await this.service.CreateApiToken(this.Tenant.UserId, Request, CancellationToken.None);
+
+ Assert.That(result.Outcome, Is.EqualTo(ApiTokenCreateOutcome.Created), "the limit counts unexpired tokens only");
+ }
+
+ [Test]
+ public async Task ConcurrentCreationsStopAtTheLimit()
+ {
+ for (var index = 0; index < ApiTokenLimits.MaxActive - 1; index++)
+ await this.service.CreateApiToken(this.Tenant.UserId, Request, CancellationToken.None);
+
+ await using var otherContext = TestDatabase.CreateMigrated(this.Tenant.UserContext);
+ var other = new ApiTokenService(new ApiTokenStore(new TransactionalDbSession(otherContext)), TimeProvider.System, NullLogger.Instance);
+
+ var results = await Task.WhenAll(
+ this.service.CreateApiToken(this.Tenant.UserId, Request, CancellationToken.None),
+ other.CreateApiToken(this.Tenant.UserId, Request, CancellationToken.None));
+
+ var count = await this.Tenant.Context.ApiTokens.CountAsync(apiToken => apiToken.UserId == this.Tenant.UserId);
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(results.Select(static result => result.Outcome), Is.EquivalentTo([ApiTokenCreateOutcome.Created, ApiTokenCreateOutcome.LimitReached]));
+ Assert.That(count, Is.EqualTo(ApiTokenLimits.MaxActive), "the second creation counts after the first has written");
+ }
+ }
+}
diff --git a/src/Tests/EvilCase.Tests/Auth/ApiTokenStoreTests.cs b/src/Tests/EvilCase.Tests/Auth/ApiTokenStoreTests.cs
new file mode 100644
index 00000000..ffdcc5fa
--- /dev/null
+++ b/src/Tests/EvilCase.Tests/Auth/ApiTokenStoreTests.cs
@@ -0,0 +1,99 @@
+using EvilBrains.EvilCase.Auth;
+using EvilBrains.EvilCase.Data.Entities;
+using EvilBrains.EvilCase.Domain.ApiTokens;
+using EvilBrains.EvilCase.Tests.Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace EvilBrains.EvilCase.Tests.Auth;
+
+public class ApiTokenStoreTests : TenantFixture
+{
+ private static readonly DateTime Now = new(2026, 10, 30, 12, 0, 0, DateTimeKind.Utc);
+
+ private static readonly TimeSpan Resolution = TimeSpan.FromMinutes(1);
+
+ [Test]
+ public async Task TheLastUseIsStampedAtMostOnceAMinute()
+ {
+ var store = new ApiTokenStore(new FixedDbSession(this.Tenant.Context));
+ var apiToken = await this.AddApiToken(this.Tenant.UserId);
+
+ await Touch(store, apiToken, Now);
+ await Touch(store, apiToken, Now.AddSeconds(59));
+ var withinTheMinute = await this.LastUsed(apiToken);
+
+ await Touch(store, apiToken, Now.AddSeconds(61));
+ var afterTheMinute = await this.LastUsed(apiToken);
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(withinTheMinute, Is.EqualTo(Now), "a use within a minute of the stamped one writes nothing");
+ Assert.That(afterTheMinute, Is.EqualTo(Now.AddSeconds(61)), "a use a minute after the stamped one moves it");
+ }
+ }
+
+ [Test]
+ public async Task ATokenIsFoundWithItsUserBeforeATenantIsKnown()
+ {
+ var apiToken = await this.AddApiToken(this.Tenant.UserId);
+
+ await using var anonymous = TestDatabase.CreateMigrated(new StubUserContext());
+ var store = new ApiTokenStore(new FixedDbSession(anonymous));
+
+ var found = await store.FindApiToken(apiToken.TokenHash, CancellationToken.None);
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(found?.Id, Is.EqualTo(apiToken.Id), "authentication runs before any tenant is known");
+ Assert.That(found?.User?.Id, Is.EqualTo(this.Tenant.UserId), "the tenant comes from the user row, so the user is read with the token");
+ }
+ }
+
+ [Test]
+ public async Task AUserRevokesOnlyTheirOwnToken()
+ {
+ var other = await this.Tenant.AddUser();
+ var apiToken = await this.AddApiToken(this.Tenant.UserId);
+ var store = new ApiTokenStore(new FixedDbSession(this.Tenant.Context));
+
+ var byOther = await store.DeleteApiToken(other.Id, apiToken.Id, CancellationToken.None);
+ var stillThere = await this.Tenant.Context.ApiTokens.AnyAsync(stored => stored.Id == apiToken.Id);
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(byOther, Is.False);
+ Assert.That(stillThere, Is.True, "another user of the same tenant cannot revoke the token");
+ }
+ }
+
+ private static async Task Touch(ApiTokenStore store, ApiToken apiToken, DateTime now)
+ {
+ await store.TouchApiToken(apiToken.Id, now, now - Resolution, CancellationToken.None);
+ }
+
+ private async Task AddApiToken(Guid userId)
+ {
+ var apiToken = new ApiToken
+ {
+ UserId = userId,
+ Name = "agent",
+ Scope = ApiTokenScope.ReadOnly,
+ TokenHash = ApiTokenValue.Hash(ApiTokenValue.Create()),
+ Expires = Now.AddDays(90),
+ };
+
+ this.Tenant.Context.ApiTokens.Add(apiToken);
+ await this.Tenant.Context.SaveChangesAsync();
+
+ return apiToken;
+ }
+
+ private async Task LastUsed(ApiToken apiToken)
+ {
+ return await this.Tenant.Context.ApiTokens
+ .AsNoTracking()
+ .Where(stored => stored.Id == apiToken.Id)
+ .Select(static stored => stored.LastUsed)
+ .SingleAsync();
+ }
+}
diff --git a/src/Tests/EvilCase.Tests/Auth/FakeApiTokenStore.cs b/src/Tests/EvilCase.Tests/Auth/FakeApiTokenStore.cs
new file mode 100644
index 00000000..b4e94c04
--- /dev/null
+++ b/src/Tests/EvilCase.Tests/Auth/FakeApiTokenStore.cs
@@ -0,0 +1,74 @@
+using EvilBrains.EvilCase.Auth;
+using EvilBrains.EvilCase.Data.Entities;
+using EvilBrains.EvilCase.Domain.ApiTokens;
+
+namespace EvilBrains.EvilCase.Tests.Auth;
+
+internal sealed class FakeApiTokenStore : IApiTokenStore
+{
+ private readonly Lock writes = new();
+
+ private readonly List tokens = [];
+
+ ///
+ /// Returns the secret, which only the caller of a real creation would see.
+ ///
+ public string Issue(User user, ApiTokenScope scope, DateTime? expires = null)
+ {
+ var secret = ApiTokenValue.Create();
+
+ lock (this.writes)
+ {
+ this.tokens.Add(new ApiToken
+ {
+ UserId = user.Id,
+ User = user,
+ Name = $"token-{this.tokens.Count}",
+ Scope = scope,
+ TokenHash = ApiTokenValue.Hash(secret),
+ Expires = expires ?? DateTime.UtcNow.AddDays(90),
+ });
+ }
+
+ return secret;
+ }
+
+ public void Forget(string secret)
+ {
+ var hash = ApiTokenValue.Hash(secret);
+
+ lock (this.writes)
+ this.tokens.RemoveAll(stored => string.Equals(stored.TokenHash, hash, StringComparison.Ordinal));
+ }
+
+ public async Task AddApiToken(ApiToken apiToken, DateTime now, int maxActive, CancellationToken token)
+ {
+ lock (this.writes)
+ this.tokens.Add(apiToken with { Created = now });
+
+ return true;
+ }
+
+ public async Task> ListApiTokens(Guid userId, CancellationToken token)
+ {
+ lock (this.writes)
+ return [.. this.tokens.Where(stored => stored.UserId == userId)];
+ }
+
+ public async Task FindApiToken(string tokenHash, CancellationToken token)
+ {
+ lock (this.writes)
+ return this.tokens.Find(stored => string.Equals(stored.TokenHash, tokenHash, StringComparison.Ordinal));
+ }
+
+ public async Task DeleteApiToken(Guid userId, Guid apiTokenId, CancellationToken token)
+ {
+ lock (this.writes)
+ return this.tokens.RemoveAll(stored => stored.Id == apiTokenId && stored.UserId == userId) > 0;
+ }
+
+ public Task TouchApiToken(Guid apiTokenId, DateTime now, DateTime staleBefore, CancellationToken token)
+ {
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/Tests/EvilCase.Tests/Data/DatabaseHistoryTests.cs b/src/Tests/EvilCase.Tests/Data/DatabaseHistoryTests.cs
index df47b530..7e433594 100644
--- a/src/Tests/EvilCase.Tests/Data/DatabaseHistoryTests.cs
+++ b/src/Tests/EvilCase.Tests/Data/DatabaseHistoryTests.cs
@@ -10,6 +10,9 @@ namespace EvilBrains.EvilCase.Tests.Data;
///
public class DatabaseHistoryTests
{
+ // Both carry a token hash and change on every use.
+ private static readonly string[] Unhistorized = ["RefreshTokens", "ApiTokens"];
+
[Test]
public async Task InsertUpdateAndDeleteWriteThreeHistoryRowsInOrder()
{
@@ -66,7 +69,7 @@ await context.Accounts
}
[Test]
- public async Task EveryMappedTableExceptRefreshTokensCarriesTheRecordTrigger()
+ public async Task EveryMappedTableExceptTheTokenTablesCarriesTheRecordTrigger()
{
await using var context = TestDatabase.CreateMigrated();
@@ -74,7 +77,7 @@ public async Task EveryMappedTableExceptRefreshTokensCarriesTheRecordTrigger()
.Select(static entityType => entityType.GetTableName())
.OfType()
.Distinct(StringComparer.Ordinal)
- .Where(static table => !string.Equals(table, "RefreshTokens", StringComparison.Ordinal))
+ .Except(Unhistorized, StringComparer.Ordinal)
.ToList();
var triggered = await context.Database
@@ -91,13 +94,13 @@ FROM pg_trigger t
using (Assert.EnterMultipleScope())
{
Assert.That(historized, Is.Not.Empty, "the model maps entities carrying history at all, or this test passes vacuously");
- Assert.That(triggered, Is.SupersetOf(historized), "a mapped table other than RefreshTokens has no record_history trigger, so its rows would be written with no history");
- Assert.That(triggered, Does.Not.Contain("RefreshTokens"), "RefreshTokens rotates on every refresh and carries the token hash, so it stays out of history");
+ Assert.That(triggered, Is.SupersetOf(historized), "a mapped table other than the token tables has no record_history trigger, so its rows would be written with no history");
+ Assert.That(triggered, Has.None.AnyOf(Unhistorized), "the token tables carry the token hash and change on every use, so they stay out of history");
}
}
[Test]
- public async Task RefreshTokensHasNoMirrorTable()
+ public async Task TheTokenTablesHaveNoMirrorTable()
{
await using var context = TestDatabase.CreateMigrated();
@@ -111,7 +114,7 @@ FROM pg_class c
""")
.ToListAsync();
- Assert.That(mirrors, Does.Not.Contain("RefreshTokens"), "RefreshTokens rotates on every refresh and carries the token hash, so it carries no mirror table");
+ Assert.That(mirrors, Has.None.AnyOf(Unhistorized), "the token tables carry the token hash and change on every use, so they carry no mirror table");
}
[Test]
@@ -122,7 +125,7 @@ public async Task EveryHistoryTableCarriesAtLeastEveryColumnOfItsTable()
var historized = context.Model.GetEntityTypes()
.Select(static entityType => entityType.GetTableName()!)
.Distinct(StringComparer.Ordinal)
- .Where(static table => !string.Equals(table, "RefreshTokens", StringComparison.Ordinal))
+ .Except(Unhistorized, StringComparer.Ordinal)
.ToList();
using (Assert.EnterMultipleScope())
diff --git a/src/Tests/EvilCase.Tests/Data/Model/TenantIsolationTests.cs b/src/Tests/EvilCase.Tests/Data/Model/TenantIsolationTests.cs
index 0a6a4f2d..726dbb6e 100644
--- a/src/Tests/EvilCase.Tests/Data/Model/TenantIsolationTests.cs
+++ b/src/Tests/EvilCase.Tests/Data/Model/TenantIsolationTests.cs
@@ -106,7 +106,7 @@ public void TheOnlyRowsOutsideATenantAreAccountsTenantsAndTokens()
Assert.That(
untenanted,
- Is.EquivalentTo(["Account", "Tenant", "RefreshToken", "CaseAncestor"]),
+ Is.EquivalentTo(["Account", "Tenant", "RefreshToken", "ApiToken", "CaseAncestor"]),
"a new entity outside the tenant is a leak until someone says otherwise");
}
diff --git a/src/Tests/EvilCase.Tests/Frontend/ApiTokenSettingsRenderTests.cs b/src/Tests/EvilCase.Tests/Frontend/ApiTokenSettingsRenderTests.cs
new file mode 100644
index 00000000..f2bfe027
--- /dev/null
+++ b/src/Tests/EvilCase.Tests/Frontend/ApiTokenSettingsRenderTests.cs
@@ -0,0 +1,105 @@
+using Bunit;
+using EvilBrains.EvilCase.Api.Client;
+using EvilBrains.EvilCase.Api.Contract.ApiTokens;
+using EvilBrains.EvilCase.App.Clipboard;
+using EvilBrains.EvilCase.App.Pages;
+using EvilBrains.EvilCase.Domain.ApiTokens;
+using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Components.Web;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace EvilBrains.EvilCase.Tests.Frontend;
+
+public class ApiTokenSettingsRenderTests
+{
+ private const string Secret = "evc_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
+
+ private static readonly ApiTokenItem Live = new()
+ {
+ ApiTokenId = Guid.CreateVersion7(),
+ Name = "Claude Code",
+ Scope = ApiTokenScope.ReadWrite,
+ Created = DateTime.UtcNow.AddDays(-1),
+ Expires = DateTime.UtcNow.AddDays(89),
+ LastUsed = DateTime.UtcNow.AddMinutes(-5),
+ };
+
+ private static readonly ApiTokenItem Expired = new()
+ {
+ ApiTokenId = Guid.CreateVersion7(),
+ Name = "Zkušební skript",
+ Scope = ApiTokenScope.ReadOnly,
+ Created = DateTime.UtcNow.AddDays(-40),
+ Expires = DateTime.UtcNow.AddDays(-10),
+ };
+
+ [Test]
+ public void TheApiTokenSettingsRender()
+ {
+ using var ctx = Context(out _);
+
+ var component = ctx.Render();
+
+ component.WaitForAssertion(() => Assert.That(component.FindAll(".ec-token-row"), Has.Count.EqualTo(3)));
+ }
+
+ [Test]
+ public void OnlyAnExpiredTokenIsMarkedExpired()
+ {
+ using var ctx = Context(out _);
+
+ var component = ctx.Render();
+ component.WaitForAssertion(() => Assert.That(component.FindAll(".ec-settings-list .ec-token-row"), Has.Count.EqualTo(2)));
+
+ var rows = component.FindAll(".ec-settings-list .ec-token-row");
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(rows[0].QuerySelectorAll(".ec-token-expired"), Is.Empty, "a token before its expiry is live");
+ Assert.That(rows[1].QuerySelectorAll(".ec-token-expired"), Is.Not.Empty, "a token past its expiry shows Vypršel");
+ }
+ }
+
+ [Test]
+ public async Task TheCreatedTokenIsShownUntilTheWindowCloses()
+ {
+ await using var ctx = Context(out var client);
+
+ client
+ .CreateApiToken(Arg.Any(), Arg.Any())
+ .Returns(new ApiTokenCreated { Token = Live, Secret = Secret });
+
+ var component = ctx.Render();
+ await component.WaitForAssertionAsync(() => Assert.That(component.FindAll(".ec-token-row"), Has.Count.EqualTo(3)));
+
+ await component.Find(".ec-card-header-actions .ec-button").ClickAsync(new MouseEventArgs());
+ await component.Find("#api-token-name").ChangeAsync(new ChangeEventArgs { Value = "Claude Code" });
+ await component.FindAll(".ec-modal-footer .ec-button")[^1].ClickAsync(new MouseEventArgs());
+
+ var shown = component.Find("#api-token-secret").GetAttribute("value");
+
+ await component.FindAll(".ec-modal-footer .ec-button")[^1].ClickAsync(new MouseEventArgs());
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(shown, Is.EqualTo(Secret));
+ Assert.That(component.Markup, Does.Not.Contain(Secret), "the secret is gone once its window closes");
+ }
+ }
+
+ private static BunitContext Context(out IApiTokensClient client)
+ {
+ var ctx = new BunitContext();
+ ctx.JSInterop.Mode = JSRuntimeMode.Loose;
+
+ client = Substitute.For();
+ client
+ .ListApiTokens(Arg.Any())
+ .Returns(new ApiTokenListResponse { Items = [Live, Expired] });
+
+ ctx.Services.AddSingleton(client);
+ ctx.Services.AddSingleton(new StubClipboardWriter());
+
+ return ctx;
+ }
+}
diff --git a/src/Tests/EvilCase.Tests/Frontend/SettingsRenderTests.cs b/src/Tests/EvilCase.Tests/Frontend/LabelSettingsRenderTests.cs
similarity index 91%
rename from src/Tests/EvilCase.Tests/Frontend/SettingsRenderTests.cs
rename to src/Tests/EvilCase.Tests/Frontend/LabelSettingsRenderTests.cs
index 9ac04543..d2d4d699 100644
--- a/src/Tests/EvilCase.Tests/Frontend/SettingsRenderTests.cs
+++ b/src/Tests/EvilCase.Tests/Frontend/LabelSettingsRenderTests.cs
@@ -8,7 +8,7 @@
namespace EvilBrains.EvilCase.Tests.Frontend;
-public class SettingsRenderTests
+public class LabelSettingsRenderTests
{
[Test]
public void TheLabelSettingsRender()
@@ -25,7 +25,7 @@ public void TheLabelSettingsRender()
ctx.Services.AddSingleton(labelsClient);
- var component = ctx.Render();
+ var component = ctx.Render();
component.WaitForAssertion(() => Assert.That(component.FindComponents(), Has.Count.EqualTo(1)));
}
diff --git a/src/Tests/EvilCase.Tests/Frontend/SettingsExportRenderTests.cs b/src/Tests/EvilCase.Tests/Frontend/SettingsExportRenderTests.cs
index a4419b5b..7fc9bf33 100644
--- a/src/Tests/EvilCase.Tests/Frontend/SettingsExportRenderTests.cs
+++ b/src/Tests/EvilCase.Tests/Frontend/SettingsExportRenderTests.cs
@@ -1,5 +1,6 @@
using Bunit;
using EvilBrains.EvilCase.App.Files;
+using EvilBrains.EvilCase.App.Layout;
using EvilBrains.EvilCase.App.Pages;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
@@ -16,8 +17,18 @@ public void TheExportSectionRendersAsTheCurrentSection()
ctx.Services.AddSingleton(new StubFileDownloader());
ctx.Services.GetRequiredService().NavigateTo("/settings/export");
- var component = ctx.Render();
+ var component = ctx.Render(static parameters => parameters.Add(
+ static layout => layout.Body,
+ static builder =>
+ {
+ builder.OpenComponent(0);
+ builder.CloseComponent();
+ }));
- Assert.That(component.Find("[aria-current=page]").TextContent.Trim(), Is.EqualTo("Export"), "the nav marks only the section shown");
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(component.Find("[aria-current=page]").TextContent.Trim(), Is.EqualTo("Export"), "the nav marks only the section shown");
+ Assert.That(component.FindAll(".ec-export-action .ec-button"), Has.Count.EqualTo(1));
+ }
}
}
diff --git a/src/Tests/EvilCase.Tests/Frontend/SettingsRedirectTests.cs b/src/Tests/EvilCase.Tests/Frontend/SettingsRedirectTests.cs
new file mode 100644
index 00000000..71051984
--- /dev/null
+++ b/src/Tests/EvilCase.Tests/Frontend/SettingsRedirectTests.cs
@@ -0,0 +1,21 @@
+using Bunit;
+using EvilBrains.EvilCase.App.Pages;
+using Microsoft.AspNetCore.Components;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace EvilBrains.EvilCase.Tests.Frontend;
+
+public class SettingsRedirectTests
+{
+ [Test]
+ public void TheSettingsRootOpensTheLabels()
+ {
+ using var ctx = new BunitContext();
+
+ ctx.Render();
+
+ var navigation = ctx.Services.GetRequiredService();
+
+ Assert.That(navigation.ToBaseRelativePath(navigation.Uri), Is.EqualTo("settings/labels"));
+ }
+}
diff --git a/src/Tests/EvilCase.Tests/Hosting/ApiTokenAuthenticationTests.cs b/src/Tests/EvilCase.Tests/Hosting/ApiTokenAuthenticationTests.cs
new file mode 100644
index 00000000..c90ff8b0
--- /dev/null
+++ b/src/Tests/EvilCase.Tests/Hosting/ApiTokenAuthenticationTests.cs
@@ -0,0 +1,214 @@
+using System.Net;
+using System.Net.Http.Headers;
+using EvilBrains.EvilCase.Api.Contract.Cases;
+using EvilBrains.EvilCase.Api.Contract.User;
+using EvilBrains.EvilCase.Auth;
+using EvilBrains.EvilCase.Business.Cases;
+using EvilBrains.EvilCase.Business.Entities;
+using EvilBrains.EvilCase.Business.Labels;
+using EvilBrains.EvilCase.Data.Entities;
+using EvilBrains.EvilCase.Domain.ApiTokens;
+using EvilBrains.EvilCase.Domain.Users;
+using EvilBrains.EvilCase.Tests.Auth;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace EvilBrains.EvilCase.Tests.Hosting;
+
+///
+/// The store is a fake; the scheme selection, the claims, the boundary and the authorization run as in production.
+///
+public class ApiTokenAuthenticationTests
+{
+ private const string CasesPath = "/api/cases?take=10";
+
+ private readonly FakeApiTokenStore store = new();
+
+ private readonly List tenantsSeen = [];
+
+ private readonly User user = new()
+ {
+ Id = Guid.CreateVersion7(),
+ TenantId = Guid.CreateVersion7(),
+ Email = "agent@evilcase.test",
+ PasswordHash = "not-verified-here",
+ Role = UserRole.User,
+ };
+
+ private EvilCaseHost host = null!;
+
+ private HttpClient client = null!;
+
+ [OneTimeSetUp]
+ public void SetUp()
+ {
+ var labels = Substitute.For();
+ labels
+ .DeleteLabel(Arg.Any(), Arg.Any())
+ .Returns(DeleteOutcome.Deleted);
+
+ this.host = new EvilCaseHost(configureServices: services =>
+ {
+ services.AddSingleton(this.store);
+ services.AddSingleton(labels);
+
+ // Records the tenant the request runs under when the reader is asked.
+ services.AddScoped(provider =>
+ {
+ var userContext = provider.GetRequiredService();
+ var reader = Substitute.For();
+
+ reader
+ .ListCases(Arg.Any(), Arg.Any())
+ .Returns(_ =>
+ {
+ this.tenantsSeen.Add(userContext.TenantId);
+
+ return new CaseListResponse { Items = [], TotalCount = 0 };
+ });
+
+ return reader;
+ });
+ });
+
+ this.client = this.host.CreateClient();
+ }
+
+ [OneTimeTearDown]
+ public void TearDown()
+ {
+ this.client.Dispose();
+ this.host.Dispose();
+ }
+
+ [Test]
+ public async Task ARequestRunsUnderTheTenantOfTheTokensUser()
+ {
+ var secret = this.store.Issue(this.user, ApiTokenScope.ReadOnly);
+ this.tenantsSeen.Clear();
+
+ using var response = await this.Send(HttpMethod.Get, CasesPath, secret);
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
+ Assert.That(this.tenantsSeen, Is.EqualTo([this.user.TenantId]), "a token acts as its user, tenant included");
+ }
+ }
+
+ [Test]
+ public async Task AReadOnlyTokenReadsButNeverWrites()
+ {
+ var secret = this.store.Issue(this.user, ApiTokenScope.ReadOnly);
+
+ using var read = await this.Send(HttpMethod.Get, CasesPath, secret);
+ using var write = await this.Send(HttpMethod.Delete, $"/api/labels/{Guid.CreateVersion7()}", secret);
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(read.StatusCode, Is.EqualTo(HttpStatusCode.OK));
+ Assert.That(write.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden), "a read-only token gets 403 on every write");
+ }
+ }
+
+ [Test]
+ public async Task AReadWriteTokenWrites()
+ {
+ var secret = this.store.Issue(this.user, ApiTokenScope.ReadWrite);
+
+ using var write = await this.Send(HttpMethod.Delete, $"/api/labels/{Guid.CreateVersion7()}", secret);
+
+ Assert.That(write.StatusCode, Is.EqualTo(HttpStatusCode.NoContent));
+ }
+
+ [Test]
+ public async Task AnExpiredTokenIsUnauthorized()
+ {
+ var secret = this.store.Issue(this.user, ApiTokenScope.ReadWrite, expires: DateTime.UtcNow.AddSeconds(-1));
+
+ using var response = await this.Send(HttpMethod.Get, CasesPath, secret);
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
+ }
+
+ [Test]
+ public async Task AnUnknownTokenIsUnauthorized()
+ {
+ using var response = await this.Send(HttpMethod.Get, CasesPath, ApiTokenValue.Create());
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
+ }
+
+ [Test]
+ public async Task ARevokedTokenStopsOnTheNextRequest()
+ {
+ var secret = this.store.Issue(this.user, ApiTokenScope.ReadOnly);
+
+ using var before = await this.Send(HttpMethod.Get, CasesPath, secret);
+ this.store.Forget(secret);
+ using var after = await this.Send(HttpMethod.Get, CasesPath, secret);
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(before.StatusCode, Is.EqualTo(HttpStatusCode.OK));
+ Assert.That(after.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized), "nothing caches a token, so the revocation holds on the very next request");
+ }
+ }
+
+ [Test]
+ public async Task ALockedOutUserKeepsTheirToken()
+ {
+ var locked = this.user with { Id = Guid.CreateVersion7(), Email = "locked@evilcase.test", LockoutEnd = DateTime.UtcNow.AddMinutes(15) };
+ var secret = this.store.Issue(locked, ApiTokenScope.ReadOnly);
+
+ using var response = await this.Send(HttpMethod.Get, CasesPath, secret);
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), "the lockout blocks the password sign-in alone");
+ }
+
+ [TestCase("GET", "/api/api-tokens")]
+ [TestCase("POST", "/api/api-tokens")]
+ [TestCase("DELETE", "/api/api-tokens/0199a1b2-0000-7000-8000-000000000000")]
+ [TestCase("GET", "/api/auth/user-info")]
+ [TestCase("GET", "/api/auth/sessions")]
+ [TestCase("POST", "/api/auth/logout-all")]
+ [TestCase("POST", "/api/auth/logout")]
+ public async Task ATokenCannotReachSignInOrTokenManagement(string method, string path)
+ {
+ var secret = this.store.Issue(this.user, ApiTokenScope.ReadWrite);
+
+ using var response = await this.Send(new HttpMethod(method), path, secret);
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden), "token management and /api/auth stay cookie sessions only");
+ }
+
+ [Test]
+ public async Task ATokenBesideTheCookieAuthenticatesTheRequest()
+ {
+ var secret = this.store.Issue(this.user, ApiTokenScope.ReadWrite);
+ var cookie = TestTokens.TokenFrom(this.host);
+
+ using var cookieOnly = await this.Send(HttpMethod.Get, "/api/api-tokens", secret: null, cookie);
+ using var both = await this.Send(HttpMethod.Get, "/api/api-tokens", secret, cookie);
+ using var unknownBeside = await this.Send(HttpMethod.Get, "/api/api-tokens", ApiTokenValue.Create(), cookie);
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(cookieOnly.StatusCode, Is.EqualTo(HttpStatusCode.OK));
+ Assert.That(both.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden), "the token wins over the cookie, and a token never manages tokens");
+ Assert.That(unknownBeside.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized), "a refused token never falls back to the cookie");
+ }
+ }
+
+ private async Task Send(HttpMethod method, string path, string? secret, string? cookie = null)
+ {
+ using var request = new HttpRequestMessage(method, new Uri(path, UriKind.Relative));
+
+ if (secret is not null)
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", secret);
+
+ if (cookie is not null)
+ request.Headers.Add("Cookie", $"{AccessCookie.Name}={cookie}");
+
+ return await this.client.SendAsync(request);
+ }
+}
diff --git a/src/Tests/EvilCase.Tests/Hosting/OpenApiDocumentTests.cs b/src/Tests/EvilCase.Tests/Hosting/OpenApiDocumentTests.cs
new file mode 100644
index 00000000..fa5670c1
--- /dev/null
+++ b/src/Tests/EvilCase.Tests/Hosting/OpenApiDocumentTests.cs
@@ -0,0 +1,29 @@
+using System.Net;
+using System.Text.Json;
+
+namespace EvilBrains.EvilCase.Tests.Hosting;
+
+///
+/// The host runs as Testing, so this is the document outside Development.
+///
+public class OpenApiDocumentTests
+{
+ [Test]
+ public async Task TheDocumentIsAnonymousAndDeclaresTheBearerScheme()
+ {
+ await using var host = new EvilCaseHost();
+ using var client = host.CreateClient();
+
+ using var response = await client.GetAsync(new Uri("/openapi/v1.json", UriKind.Relative));
+ using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
+
+ var scheme = document.RootElement.GetProperty("components").GetProperty("securitySchemes").GetProperty("Bearer");
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), "an integration reads the contract before it holds a token");
+ Assert.That(scheme.GetProperty("type").GetString(), Is.EqualTo("http"));
+ Assert.That(scheme.GetProperty("scheme").GetString(), Is.EqualTo("bearer"));
+ }
+ }
+}