Keystone — API Reference
What this document is: the actual, current shape of Keystone's HTTP surface — real routes, real request/response fields, real auth requirements, real enum/parameter values — read directly out of the source code (Keystone.API.Web/Endpoints, Keystone.API.Core, Keystone.API.Infrastructure) on 2026-10-01.
What this document is not: a design record. It carries no rationale, no history, no "why we decided this." For that, read keystone-prd.md, keystone-framing.md, and keystone-external-login-proposal.md — those describe intent at a point in time and are expected to drift from the real build as it evolves. If this document and any other Keystone doc ever disagree on what an endpoint actually does, this one is correct, because it was generated by reading the code, not by reading other documentation.
This doc should be regenerated (not hand-patched) whenever someone needs to re-verify it — read the real source again rather than editing this file from memory.
Auth schemes
| Scheme | Mechanism | Used by |
|---|---|---|
LoginCookie | Interactive cookie, set by /account/login | Browser-based account endpoints |
ApiKey (scheme name "ApiKey") | X-Keystone-ApiKey header, constant-time compared against ApiKeys:CommandCenter config | Service-to-service calls from Command Center |
| OpenIddict validation scheme | Standard Authorization: Bearer <token> | Any endpoint protecting a resource with an issued access token (e.g. diagnostics) |
External login (Microsoft Entra) was fully removed from the codebase on 2026-10-01 — not disabled, not dormant, gone. It was built, deployed, then disabled in production on 2026-09-28 after a startup-crash incident and a credential-exposure incident (see keystone-production-secrets.md); Pete then made the final call that it isn't coming back, and the endpoints, the ExternalLogin domain entity, and its EF migration were removed and verified directly against source. There is nothing left in the live code for this document to describe. For the historical design and what was actually built and tested, see keystone-prd.md §§3.4–3.7, keystone-external-login-proposal.md, and keystone-api-design.md (all marked historical) — not this document, which only ever describes what currently exists.
Endpoints
Account
| Method & Path | Auth | Purpose |
|---|---|---|
POST /account/register | Anonymous | Primary, human-facing registration. Request: { email, password }. Response 200: { userId }. 409 if email taken, 400 on validation failure. Creates a User row directly — never touches Entra. |
POST /v1/accounts/provision | ApiKey | Service-to-service registration, used internally by Command Center's signup orchestration. Request: { email, password }. Response 200: { userId, wasAlreadyRegistered } — always 200, idempotent; wasAlreadyRegistered (not the status code) tells you whether this was a first-time create. Separate from /account/register deliberately: different trust tier, no per-person rate limiting, immune to anti-bot measures aimed at the public endpoint. |
GET /account/login | Anonymous | Renders the bare-bones hosted login form (no styling/JS — no product conversation has happened yet on how this should look). |
POST /account/login | Anonymous (form-encoded) | Request: Email, Password, ReturnUrl?. Verifies credentials, sets LoginCookie, redirects to ReturnUrl (open-redirect guarded — only same-origin paths accepted) or back to the login form with ?error=1. |
POST /account/password-reset/request | Anonymous | Request: { email }. Always returns 202 regardless of whether the account exists (anti-enumeration). |
POST /account/password-reset/confirm | Anonymous | Request: { resetToken, newPassword }. 200 on success (all other sessions implicitly revoked — all other outstanding reset tokens are invalidated in the same step); 400 if the token is invalid or expired. |
Connect (OpenIddict)
| Method & Path | Auth | Purpose |
|---|---|---|
GET /connect/authorize | Anonymous (redirects to login if no valid cookie) | Standard OAuth/OIDC authorize endpoint. OpenIddict validates client_id/redirect_uri/PKCE/scopes before handing control here. |
POST /connect/token | Anonymous (OpenIddict validates client credentials) | Supports client_credentials and authorization_code grants only. Any other grant type throws NotImplementedException. |
Diagnostics
| Method & Path | Auth | Purpose |
|---|---|---|
GET /diagnostics/protected-ping | OpenIddict validation scheme (bearer token) | Proves a token is accepted. Returns { message, subject, checkedAtUtc }. |
Data model (current, from the live EF Core migration snapshot)
Users
| Column | Type | Notes |
|---|---|---|
Id | uniqueidentifier | PK |
Email | nvarchar(256), required | Unique index |
PasswordHash | nvarchar(256), nullable at the DB/column level | The column itself is still nullable (unchanged by the external-login removal), but as of 2026-10-01 there is no remaining code path that creates a User without one — User.RegisterWithPassword is the only public factory left, and it throws if the hash is missing. Null is now a schema-level possibility, not a real application state. See hashing parameters below. |
EmailVerified | bit | |
IsActive | bit | |
CreatedUtc | datetime2 |
PasswordResetTokens
| Column | Type | Notes |
|---|---|---|
TokenHash | nvarchar(50) | PK (the hash itself is the key, not a separate id) |
UserId | uniqueidentifier | FK → Users, cascade delete |
ExpiresUtc | datetime2 | |
UsedUtc | datetime2, nullable | Null = still valid (subject to expiry) |
Password hashing — the exact parameters (Pbkdf2PasswordHasher)
This is the parameter set that cannot safely be reconstructed by a raw SQL insert — getting any of it wrong produces an account that silently can never log in:
- Algorithm: PBKDF2-HMAC-SHA256
- Iteration count:
600,000(OWASP 2026 minimum for this algorithm) - Salt: 16 bytes (128 bits), cryptographically random, generated fresh per password
- Subkey (derived key) length: 32 bytes (256 bits)
- Stored format: a single Base64 string packing
[4-byte big-endian iteration count][16-byte salt][32-byte subkey](52 bytes total before Base64) — self-describing, so a future change to the iteration count doesn't invalidate existing hashes - Verification: unpacks the three fields, re-derives with the stored iteration count (not the current constant), compares with
CryptographicOperations.FixedTimeEquals
This is not ASP.NET Core Identity's PasswordHasher — a deliberate choice to avoid taking a dependency on Identity, implemented as this project's own packed-format equivalent.
Practical implication: there is no safe way to insert a working password credential except by calling Pbkdf2PasswordHasher.HashPassword (in-process) or going through POST /account/register / POST /v1/accounts/provision. Do not attempt to replicate this by hand in SQL.
Known gaps — needs live verification, not assumption
- This document reflects the solution as zipped and shared on 2026-10-01 (deployed to production the same day, per Pete). Anything merged to the real repo after that isn't reflected here until this doc is regenerated.
- Production configuration (which secrets are actually set, which auth schemes are actually registered right now) is tracked separately in
keystone-production-secrets.md— this document describes what the code supports, not live production state.
Document history
| Date | Change |
|---|---|
| 2026-09-30 | Initial version, generated from source |
| 2026-10-01 | Regenerated after Pete fully removed external login (Microsoft Entra) from the codebase — endpoints, the ExternalLogin domain entity, its EF migration, the now-unused Microsoft.AspNetCore.Authentication.OpenIdConnect/Microsoft.IdentityModel.Validators package references, and the corresponding test-layer config, all verified gone. This document never described the feature as live to begin with once it was disabled; it now has no trace of it at all, matching the real code. |