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
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.
186 lines
11 KiB
Markdown
186 lines
11 KiB
Markdown
# 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.
|