Files
ombi-mcp/docs/schema/01-authentication.md
gronod 49bc0775de
Build and publish / Test and build (windows) (push) Failing after 11s
Build and publish / Test and build (darwin) (push) Successful in 1m32s
Build and publish / Test and build (linux) (push) Canceled after 0s
Build and publish / Build and publish Docker image (push) Canceled after 0s
Add HTTP/SSE transport alongside stdio (Phase 09)
Network MCP clients (e.g. browser-based frontends) can't spawn stdio
subprocesses, so the server now optionally serves the 2024-11-05 MCP
HTTP/SSE transport via MCP_TRANSPORT=sse and MCP_PORT (default 8080),
backed by the SDK's SSEHandler behind permissive CORS. stdio remains
the default and is behaviourally unchanged.
2026-09-18 21:13:09 +01:00

186 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Authentication and authorization
## Upstream authentication: explicit mode selection
The server authenticates to Ombi using one of two upstream credential mechanisms, selected **explicitly** by the required `OMBI_AUTH_MODE` environment variable (locked decision D2):
- `jwt` — **primary** mechanism. Username/password login against `POST /api/v1/Token`; the server manages a Bearer-token lifecycle internally.
- `api_key` — secondary mechanism. Static `ApiKey` header, optionally scoped to a specific Ombi user principal via the `UserName` header.
There is no implicit selection and no runtime cross-mode failover: rejected JWT credentials never fall back to `api_key`, and vice versa. Missing or partial credentials for the selected mode fail startup.
## 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. |
| `MCP_TRANSPORT` | optional (default `stdio`) | both | Client-facing transport: `stdio` or `sse`. Unrecognised → startup failure. |
| `MCP_PORT` | optional (default `8080`) | both | TCP listen port for the `sse` transport; must be 1–65535. Ignored under `stdio`. |
**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).
### `OMBI_URL` handling
Only deployment configuration chooses upstream origins. Reject URLs with embedded credentials, query strings or fragments. Preserve a configured base-path prefix when appending API routes; simply resolving an absolute `/api/...` URL against a prefixed base can discard that prefix. Encode every path segment and query argument independently. Use TLS where configured; permit intentionally configured local HTTP instances without silently weakening certificate checks. Do not follow cross-origin redirects with credentials.
## JWT mode (primary) — `AuthManager` lifecycle
### Wire contract
- `POST /api/v1/Token` body `{"username": "<OMBI_USERNAME>", "password": "<OMBI_PASSWORD>"}` → 200 `{"access_token": "...", "expiration": "..."}` ; 401 → `AUTHENTICATION_FAILED` startup abort. (**Documented** — RAML `UserAuthModel`/`Token` types, ledger IDs 361–366.)
- 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**. `rememberMe` is not claimed to alter lifetime.
### 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.
**Retry policy contract:** 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`.
### Go skeleton — `internal/ombi/auth.go`
Baseline for the implementation:
```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
```
## 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; deliberate exclusion).
The global RAML security declaration does not describe the effective Ombi identity, per-operation permissions or anonymous exceptions. API-key possession must not be equated with a human user, unlimited admin privilege or a particular quota principal.
## MCP client authorization is separate
For a local stdio deployment, use process/environment isolation. For a protected HTTP deployment, implement MCP transport authorization independently from Ombi credentials, including token audience validation and protected-resource discovery. Never accept an arbitrary client's Ombi token as the MCP server's bearer token or pass MCP access tokens through to Ombi. These are different trust boundaries. See [MCP authorization](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) and [security guidance](https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices).
A single configured Ombi principal means all authorized clients share that principal's upstream power — no claimed per-client isolation. Multi-user hosting needs an explicit client-to-principal binding and isolated credentials/caches.
## Policy and secret boundaries
Credentials and authentication endpoints are never tools. No tool input accepts credential fields; schemas must not contain `api_key`, `token`, `password`, `auth` properties. Administrative settings mutations accept typed non-secret patches only; credential replacement and destination changes remain in the administrator's UI. `settings_read` returns an allowlisted projection and an opaque revision token, not a redacted full object intended for blind round-tripping.
`OMBI_BUNDLES` deployment bundles (`core`, `moderation`, `administration`) are server policy names, not Ombi claims or MCP-standard scopes. Authorize every call and branch even if it was advertised earlier. Denial is never an invitation to retry with administrator credentials.
Apply output allowlists recursively: user entities, issue comments, requests, stats, integration responses and exception bodies can contain credentials or private data. Never forward raw headers, stack traces, signed query strings, webhook URLs or token-bearing media links. Audit sanitized operation identity, target, outcome and correlation ID; do not audit credentials or full raw bodies.