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.
11 KiB
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 againstPOST /api/v1/Token; the server manages a Bearer-token lifecycle internally.api_key— secondary mechanism. StaticApiKeyheader, optionally scoped to a specific Ombi user principal via theUserNameheader.
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/Tokenbody{"username": "<OMBI_USERNAME>", "password": "<OMBI_PASSWORD>"}→ 200{"access_token": "...", "expiration": "..."}; 401 →AUTHENTICATION_FAILEDstartup abort. (Documented — RAMLUserAuthModel/Tokentypes, ledger IDs 361–366.)- Login bypasses the global
ApiKeyAuthrequirement — marked Verify (RAML appliessecuredByglobally; Ombi obviously cannot require the token you are requesting). - Bearer acceptance on all routes — Verify;
Authorization: Bearer <token>header. /api/v1/Token/refreshtakes{token, userename}(typo preserved verbatim — never "correct" it); refresh is a secondary optimisation. Primary renewal = fullPOST /api/v1/Tokenre-login. Whether refresh preserves lifetime/roles → Verify.rememberMeis not claimed to alter lifetime.
Lifecycle rules
- Startup: fetch token synchronously; abort startup on network failure, non-200, or missing
access_token. - Cache in memory only. Never persist, never log the token or
expirationraw value. - Expiry resolution order: (a) parse
expirationas RFC3339; (b) decode JWTexpclaim (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. - Renewal: mutex-locked single-flight — when the token is expired/expiring, exactly one goroutine performs the re-login while others block; no stampede.
- 401 handling: on an upstream
401mid-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. - No mode failover: rejected JWT credentials never fall back to
api_key, and vice versa. - 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:
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>; plusUserName: <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).
UserNameheader binds an existing Ombi user: calls run under that user's quota/permissions without granting global admin. Unknown username → upstream rejects (401/connection abort); surfaceAUTHENTICATION_FAILED, never silently degrade to admin.ApiAliasheader 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 and security guidance.
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.