Schema docs (docs/schema) cover tool mapping, input/output contracts and endpoint coverage for all 31 MCP tools; megaplan M1 phases 01-03 applied (namespace rename to read_*/write_*, input schema rectification). Go module provides config loading, Ombi client, and tool registry skeleton for implementation.
186 lines
9.4 KiB
Markdown
186 lines
9.4 KiB
Markdown
# Phase 02 — Upstream Authentication & Credential Architecture
|
||
|
||
## Objective
|
||
|
||
Rewrite `01-authentication.md` so JWT is the **primary** upstream mechanism (not an optional adapter), selected explicitly by `OMBI_AUTH_MODE`, with the `ApiKey` + `UserName` header pair as the documented secondary mode.
|
||
|
||
## Prerequisites
|
||
|
||
- Phase 00 complete. Phase 01 recommended first so prose uses the new tool names consistently.
|
||
- Source document to rewrite: `docs/schema/01-authentication.md`.
|
||
- Locked decision D2: explicit mode selector — `OMBI_AUTH_MODE` is a required env var, `jwt` or `api_key`; missing or partial credentials for the selected mode fail startup; no implicit selection, no runtime cross-mode failover.
|
||
|
||
## Specification
|
||
|
||
### Environment variable contract
|
||
|
||
| Variable | Required | Applies to | Semantics |
|
||
|---|---|---|---|
|
||
| `OMBI_URL` | always | both | Base URL; reject embedded credentials, query strings, fragments; preserve configured base-path prefix when appending `/api/...`. |
|
||
| `OMBI_AUTH_MODE` | always | both | Exactly `jwt` or `api_key`. Absent or unrecognised → startup failure. |
|
||
| `OMBI_USERNAME` | mode=`jwt` | jwt | Login username sent to `POST /api/v1/Token`. |
|
||
| `OMBI_PASSWORD` | mode=`jwt` | jwt | Login password sent to `POST /api/v1/Token`. |
|
||
| `OMBI_API_KEY` | mode=`api_key` | api_key | Sent as `ApiKey` header on every upstream request. |
|
||
| `OMBI_USER_NAME` | optional | api_key | Sent as `UserName` header on every request; binds the call to a specific Ombi user principal (its quota and permissions). Unknown username → upstream failure, surfaced as `AUTHENTICATION_FAILED`. |
|
||
| `OMBI_BUNDLES` | optional (default `core`) | both | Comma-separated subset of `core,moderation,administration`; controls tools/list advertisement. |
|
||
|
||
Startup validation matrix: mode selected → check the mode's required vars are all present and non-empty → else `os.Exit` with a descriptive error **before** any upstream call. Setting variables for both modes simultaneously is legal but the non-selected mode's are ignored with a startup warning log (never a silent failover path).
|
||
|
||
### JWT mode (primary) — `AuthManager` design
|
||
|
||
Wire contract (ledger IDs 361–366, RAML `UserAuthModel`/`Token` types):
|
||
- `POST /api/v1/Token` body `{"username": "<OMBI_USERNAME>", "password": "<OMBI_PASSWORD>"}` → 200 `{"access_token": "...", "expiration": "..."}` ; 401 → `AUTHENTICATION_FAILED` startup abort.
|
||
- Login bypasses the global `ApiKeyAuth` requirement — marked **Verify** (RAML applies `securedBy` globally; Ombi obviously cannot require the token you are requesting).
|
||
- Bearer acceptance on all routes — **Verify**; `Authorization: Bearer <token>` header.
|
||
- `/api/v1/Token/refresh` takes `{token, userename}` (typo preserved verbatim — never "correct" it); refresh is a secondary optimisation. Primary renewal = full `POST /api/v1/Token` re-login. Whether refresh preserves lifetime/roles → **Verify**.
|
||
|
||
Lifecycle rules:
|
||
1. **Startup**: fetch token synchronously; abort startup on network failure, non-200, or missing `access_token`.
|
||
2. **Cache in memory only.** Never persist, never log the token or `expiration` raw value.
|
||
3. **Expiry resolution order**: (a) parse `expiration` as RFC3339; (b) decode JWT `exp` claim (decode ≠ signature validation — do not claim it); (c) neither present → do not cache indefinitely — assign a conservative TTL of 3600s. Always subtract a 60s clock-skew margin.
|
||
4. **Renewal**: mutex-locked single-flight — when the token is expired/expiring, exactly one goroutine performs the re-login while others block; no stampede.
|
||
5. **401 handling**: on an upstream `401` mid-operation, invalidate the cached token, single-flight renew, then retry **reads at most once**. A timed-out or disconnected write has unknown outcome → `UNKNOWN_OUTCOME`, never auto-replay. A write may be retried after 401 only when the adapter can prove rejection before execution.
|
||
6. **No mode failover**: rejected JWT credentials never fall back to `api_key`, and vice versa.
|
||
7. Capability probing never triggers writes/jobs/notifications (carried over).
|
||
|
||
### API-key mode (secondary) — `ApiKey` + `UserName` principal
|
||
|
||
- Every request: `ApiKey: <OMBI_API_KEY>`; plus `UserName: <OMBI_USER_NAME>` when configured.
|
||
- Bare API key = admin-equivalent principal without a user context (upstream-documented behaviour — **Documented** via Ombi docs, though absent from the RAML security scheme description → label the *quota/permission semantics* **Verify**).
|
||
- `UserName` header binds an existing Ombi user: calls run under that user's quota/permissions without granting global admin. Unknown username → upstream rejects (401/connection abort); surface `AUTHENTICATION_FAILED`, never silently degrade to admin.
|
||
- `ApiAlias` header exists upstream for arbitrary aliases — excluded from the MCP surface (**Design**: no tool or env exposes it; note as deliberate exclusion).
|
||
|
||
### Boundaries (carried over verbatim — do not regress)
|
||
|
||
- MCP client authorization is a separate trust boundary: stdio → process/env isolation; HTTP deployment → independent MCP transport auth; never accept a client's Ombi token as the MCP bearer or pass MCP tokens to Ombi.
|
||
- Single configured upstream principal shared by all clients; no claimed per-client isolation.
|
||
- Credentials/auth endpoints are never tools; no tool input accepts credential fields; schemas must not contain `api_key`, `token`, `password`, `auth` properties.
|
||
|
||
### Go skeleton (baseline for `internal/ombi/auth.go` — printed in full)
|
||
|
||
```go
|
||
package ombi
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// AuthMode is the selected upstream credential mechanism.
|
||
type AuthMode string
|
||
|
||
const (
|
||
AuthModeJWT AuthMode = "jwt"
|
||
AuthModeAPIKey AuthMode = "api_key"
|
||
)
|
||
|
||
// Credentials holds only what the selected mode requires.
|
||
type Credentials struct {
|
||
Mode AuthMode
|
||
Username string // OMBI_USERNAME (jwt)
|
||
Password string // OMBI_PASSWORD (jwt)
|
||
APIKey string // OMBI_API_KEY (api_key)
|
||
UserName string // OMBI_USER_NAME (api_key, optional)
|
||
}
|
||
|
||
// AuthManager supplies per-request upstream credentials and owns the
|
||
// JWT lifecycle. Safe for concurrent use.
|
||
type AuthManager struct {
|
||
creds Credentials
|
||
base string
|
||
http *http.Client
|
||
|
||
mu sync.Mutex
|
||
token string
|
||
expiresAt time.Time
|
||
renewing chan struct{} // non-nil while a single-flight renewal runs
|
||
}
|
||
|
||
const expirySkew = 60 * time.Second
|
||
const fallbackTokenTTL = time.Hour
|
||
|
||
// Apply sets the authentication headers on an outgoing upstream request.
|
||
// In api_key mode it sets ApiKey (+ optional UserName) and returns.
|
||
// In jwt mode it blocks until a valid Bearer token is available.
|
||
func (a *AuthManager) Apply(ctx context.Context, req *http.Request) error {
|
||
if a.creds.Mode == AuthModeAPIKey {
|
||
req.Header.Set("ApiKey", a.creds.APIKey)
|
||
if a.creds.UserName != "" {
|
||
req.Header.Set("UserName", a.creds.UserName)
|
||
}
|
||
return nil
|
||
}
|
||
tok, err := a.token(ctx)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
req.Header.Set("Authorization", "Bearer "+tok)
|
||
return nil
|
||
}
|
||
|
||
// Invalidate discards the cached token (called on upstream 401).
|
||
func (a *AuthManager) Invalidate() {
|
||
a.mu.Lock()
|
||
a.token = ""
|
||
a.expiresAt = time.Time{}
|
||
a.mu.Unlock()
|
||
}
|
||
|
||
// token returns a valid token, single-flight renewing if expired.
|
||
func (a *AuthManager) token(ctx context.Context) (string, error) {
|
||
a.mu.Lock()
|
||
if a.token != "" && time.Now().Add(expirySkew).Before(a.expiresAt) {
|
||
defer a.mu.Unlock()
|
||
return a.token, nil
|
||
}
|
||
if a.renewing != nil { // another caller renews; wait for it
|
||
done := a.renewing
|
||
a.mu.Unlock()
|
||
select {
|
||
case <-done:
|
||
return a.token(ctx)
|
||
case <-ctx.Done():
|
||
return "", ctx.Err()
|
||
}
|
||
}
|
||
a.renewing = make(chan struct{})
|
||
a.mu.Unlock()
|
||
defer func() {
|
||
a.mu.Lock()
|
||
close(a.renewing)
|
||
a.renewing = nil
|
||
a.mu.Unlock()
|
||
}()
|
||
return a.login(ctx)
|
||
}
|
||
|
||
func (a *AuthManager) login(ctx context.Context) (string, error) {
|
||
return "", errors.New("implemented by phase 02 executor: POST /api/v1/Token")
|
||
}
|
||
|
||
var _ = fmt.Sprintf // placeholder import guard — remove on implementation
|
||
```
|
||
|
||
Retry policy contract (doc text): "A read is retried at most once after a definite authentication rejection and successful single-flight renewal. Writes are retried after 401 only when pre-execution rejection is proven. Timeout/disconnect → `UNKNOWN_OUTCOME`, `retryable:false`."
|
||
|
||
## Target artefacts
|
||
|
||
- `docs/schema/01-authentication.md` — rewritten per the specification above: env-var matrix, startup validation, JWT-primary `AuthManager` lifecycle, api_key secondary mode, boundaries, retry policy, and the Go skeleton for `internal/ombi/auth.go`.
|
||
|
||
## Verification gates
|
||
|
||
- [ ] `01-authentication.md` describes JWT as primary, `OMBI_AUTH_MODE` required, and contains the full env-var matrix.
|
||
- [ ] No wording presents JWT as optional or API key as the default/standard credential.
|
||
- [ ] `userename` typo preserved; `rememberMe` not claimed to alter lifetime.
|
||
- [ ] Startup-failure and no-cross-mode-failover rules present verbatim.
|
||
|
||
## Evidence labels
|
||
|
||
- **Documented** — `POST /api/v1/Token` wire contract and `UserAuthModel`/`Token` types (RAML-backed, ledger IDs 361–366); `ApiKey`/`UserName` header behaviour per Ombi docs.
|
||
- **Design** — `OMBI_AUTH_MODE` explicit selector (D2), in-memory-only token cache, single-flight renewal, read-retry-once/write-no-replay policy, `ApiAlias` deliberate exclusion.
|
||
- **Verify** — login bypassing global `ApiKeyAuth`; Bearer acceptance on all routes; refresh lifetime/role semantics; bare-API-key quota/permission semantics; `UserName`-header principal binding.
|