Files
ombi-mcp/docs/megaplans/m1/02-upstream-authentication.md
gronod 722108ebd0 Initial commit: Ombi MCP server design docs and Go skeleton
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.
2026-09-18 15:55:44 +01:00

9.4 KiB
Raw Permalink Blame History

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)

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.