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.
9.4 KiB
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_MODEis a required env var,jwtorapi_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/Tokenbody{"username": "<OMBI_USERNAME>", "password": "<OMBI_PASSWORD>"}→ 200{"access_token": "...", "expiration": "..."}; 401 →AUTHENTICATION_FAILEDstartup abort.- 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.
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 (carried over).
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; 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,authproperties.
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-primaryAuthManagerlifecycle, api_key secondary mode, boundaries, retry policy, and the Go skeleton forinternal/ombi/auth.go.
Verification gates
01-authentication.mddescribes JWT as primary,OMBI_AUTH_MODErequired, and contains the full env-var matrix.- No wording presents JWT as optional or API key as the default/standard credential.
userenametypo preserved;rememberMenot claimed to alter lifetime.- Startup-failure and no-cross-mode-failover rules present verbatim.
Evidence labels
- Documented —
POST /api/v1/Tokenwire contract andUserAuthModel/Tokentypes (RAML-backed, ledger IDs 361–366);ApiKey/UserNameheader behaviour per Ombi docs. - Design —
OMBI_AUTH_MODEexplicit selector (D2), in-memory-only token cache, single-flight renewal, read-retry-once/write-no-replay policy,ApiAliasdeliberate 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.