From 5051f90ddccc61f92bbddb28f73bdf99087b4669 Mon Sep 17 00:00:00 2001 From: Tetsuro Mikami Date: Thu, 2 Jul 2026 09:15:27 +0900 Subject: [PATCH 1/3] refactor(server): introduce Authenticator abstraction behind the auth middleware --- internal/server/auth.go | 130 +++++++++++++++++++++++++++------ internal/server/auth_test.go | 89 ++++++++++++++++++---- internal/server/export_test.go | 6 +- internal/server/router.go | 4 +- internal/server/server.go | 15 +++- 5 files changed, 201 insertions(+), 43 deletions(-) diff --git a/internal/server/auth.go b/internal/server/auth.go index 1e07359..10fd181 100644 --- a/internal/server/auth.go +++ b/internal/server/auth.go @@ -1,23 +1,56 @@ package server import ( + "context" "crypto/sha256" "crypto/subtle" + "errors" "net/http" "strings" ) -// authBearer hashes both the configured and presented tokens with SHA-256 -// before comparing, so length differences cannot leak through the -// constant-time compare's early-return-on-mismatched-length path. /healthz -// stays open regardless of token so liveness probes do not need it. -func authBearer(token string, next http.Handler) http.Handler { - if token == "" { - return next - } +// Principal is the authenticated identity attached to a request's context by +// the authenticate middleware. Handlers read it to make authorization +// decisions. A request served with authentication disabled still carries a +// Principal — an empty, anonymous one — so downstream code never has to guard +// against a missing value. +type Principal struct { + Subject string + Roles []string + Claims map[string]any +} + +// Authenticator resolves the Principal for a request. A nil error means the +// request is authenticated (possibly anonymously); returning an *authError +// rejects it and lets the middleware render the matching 401. +type Authenticator interface { + Authenticate(r *http.Request) (Principal, error) +} + +var ( + _ Authenticator = noneAuth{} + _ Authenticator = staticTokenAuth{} +) + +// authError carries what the authenticate middleware needs to render a 401: +// the WWW-Authenticate challenge and the problem-detail message. Authenticators +// return it instead of writing to the ResponseWriter so their logic stays pure +// and testable. +type authError struct { + wwwAuthenticate string + detail string +} + +func (e *authError) Error() string { return e.detail } - expected := sha256.Sum256([]byte(token)) +type contextKey int +const principalKey contextKey = iota + +// authenticate resolves the Principal via a and stores it in the request +// context before calling next. /healthz stays open regardless of the +// authenticator so liveness probes need no credentials. +func authenticate(a Authenticator, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if isHealthzPath(r.URL.Path) { next.ServeHTTP(w, r) @@ -25,28 +58,77 @@ func authBearer(token string, next http.Handler) http.Handler { return } - got, ok := bearerToken(r.Header.Get("Authorization")) - if !ok { - w.Header().Set("WWW-Authenticate", `Bearer realm="adms", error="invalid_request"`) - writeProblem(w, r, http.StatusUnauthorized, - "unauthenticated", "Unauthenticated", - "request requires a Bearer token in the Authorization header") + p, err := a.Authenticate(r) + if err != nil { + writeAuthError(w, r, err) return } - gotHash := sha256.Sum256([]byte(got)) - if subtle.ConstantTimeCompare(gotHash[:], expected[:]) != 1 { - w.Header().Set("WWW-Authenticate", `Bearer realm="adms", error="invalid_token"`) - writeProblem(w, r, http.StatusUnauthorized, - "unauthenticated", "Unauthenticated", - "the Authorization token is invalid") + ctx := context.WithValue(r.Context(), principalKey, p) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} - return +func writeAuthError(w http.ResponseWriter, r *http.Request, err error) { + detail := "authentication failed" + + if ae, ok := errors.AsType[*authError](err); ok { + if ae.wwwAuthenticate != "" { + w.Header().Set("WWW-Authenticate", ae.wwwAuthenticate) } - next.ServeHTTP(w, r) - }) + detail = ae.detail + } + + writeProblem(w, r, http.StatusUnauthorized, "unauthenticated", "Unauthenticated", detail) +} + +// principalFrom returns the Principal stored by the authenticate middleware. +// ok is false on requests that bypass authentication (e.g. /healthz). +func principalFrom(ctx context.Context) (Principal, bool) { + p, ok := ctx.Value(principalKey).(Principal) + + return p, ok +} + +// noneAuth authenticates every request as anonymous. It is selected when no +// token is configured, preserving the fully-open behavior. +type noneAuth struct{} + +func (noneAuth) Authenticate(*http.Request) (Principal, error) { + return Principal{}, nil +} + +// staticTokenAuth accepts a single shared Bearer token. Both the configured +// and presented tokens are hashed with SHA-256 before a constant-time compare, +// so a length difference cannot leak through the compare's early-return path. +type staticTokenAuth struct { + expected [sha256.Size]byte +} + +func newStaticTokenAuth(token string) staticTokenAuth { + return staticTokenAuth{expected: sha256.Sum256([]byte(token))} +} + +func (a staticTokenAuth) Authenticate(r *http.Request) (Principal, error) { + got, ok := bearerToken(r.Header.Get("Authorization")) + if !ok { + return Principal{}, &authError{ + wwwAuthenticate: `Bearer realm="adms", error="invalid_request"`, + detail: "request requires a Bearer token in the Authorization header", + } + } + + gotHash := sha256.Sum256([]byte(got)) + if subtle.ConstantTimeCompare(gotHash[:], a.expected[:]) != 1 { + return Principal{}, &authError{ + wwwAuthenticate: `Bearer realm="adms", error="invalid_token"`, + detail: "the Authorization token is invalid", + } + } + + return Principal{Subject: "static-token"}, nil } func isHealthzPath(p string) bool { diff --git a/internal/server/auth_test.go b/internal/server/auth_test.go index c300235..f1f19a4 100644 --- a/internal/server/auth_test.go +++ b/internal/server/auth_test.go @@ -12,14 +12,26 @@ import ( "github.com/mickamy/adms/internal/server" ) -func TestAuthBearer_EmptyTokenIsNoOp(t *testing.T) { +// authMiddleware builds the authenticate middleware the way the server wires +// it: an empty token stays fully open (noneAuth), a set token gates behind the +// shared Bearer token (staticTokenAuth). The migration from the old authBearer +// helper must not change any of these HTTP behaviors. +func authMiddleware(token string, next http.Handler) http.Handler { + if token == "" { + return server.Authenticate(server.NoneAuth{}, next) + } + + return server.Authenticate(server.NewStaticTokenAuth(token), next) +} + +func TestAuthenticate_NoneAuthAllowsAllRequests(t *testing.T) { t.Parallel() ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(w, "ok") }) - ts := httptest.NewServer(server.AuthBearer("", ok)) + ts := httptest.NewServer(authMiddleware("", ok)) t.Cleanup(ts.Close) resp := httpGet(t, ts.URL+"/") @@ -35,7 +47,7 @@ func TestAuthBearer_EmptyTokenIsNoOp(t *testing.T) { } } -func TestAuthBearer_AcceptsValidToken(t *testing.T) { +func TestAuthenticate_AcceptsValidToken(t *testing.T) { t.Parallel() const token = "s3cret" @@ -47,7 +59,7 @@ func TestAuthBearer_AcceptsValidToken(t *testing.T) { w.WriteHeader(http.StatusOK) }) - ts := httptest.NewServer(server.AuthBearer(token, next)) + ts := httptest.NewServer(authMiddleware(token, next)) t.Cleanup(ts.Close) req := newRequest(t, ts.URL+"/") @@ -69,14 +81,14 @@ func TestAuthBearer_AcceptsValidToken(t *testing.T) { } } -func TestAuthBearer_LowercaseSchemeAccepted(t *testing.T) { +func TestAuthenticate_LowercaseSchemeAccepted(t *testing.T) { t.Parallel() const token = "s3cret" ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) - ts := httptest.NewServer(server.AuthBearer(token, ok)) + ts := httptest.NewServer(authMiddleware(token, ok)) t.Cleanup(ts.Close) req := newRequest(t, ts.URL+"/") @@ -94,14 +106,14 @@ func TestAuthBearer_LowercaseSchemeAccepted(t *testing.T) { } } -func TestAuthBearer_RejectsMissingHeader(t *testing.T) { +func TestAuthenticate_RejectsMissingHeader(t *testing.T) { t.Parallel() next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { t.Error("next handler should not be invoked without credentials") }) - ts := httptest.NewServer(server.AuthBearer("s3cret", next)) + ts := httptest.NewServer(authMiddleware("s3cret", next)) t.Cleanup(ts.Close) resp := httpGet(t, ts.URL+"/") @@ -118,14 +130,14 @@ func TestAuthBearer_RejectsMissingHeader(t *testing.T) { assertProblemJSON(t, resp, "unauthenticated", http.StatusUnauthorized) } -func TestAuthBearer_RejectsWrongToken(t *testing.T) { +func TestAuthenticate_RejectsWrongToken(t *testing.T) { t.Parallel() next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { t.Error("next handler should not be invoked with bad token") }) - ts := httptest.NewServer(server.AuthBearer("s3cret", next)) + ts := httptest.NewServer(authMiddleware("s3cret", next)) t.Cleanup(ts.Close) req := newRequest(t, ts.URL+"/") @@ -149,7 +161,7 @@ func TestAuthBearer_RejectsWrongToken(t *testing.T) { assertProblemJSON(t, resp, "unauthenticated", http.StatusUnauthorized) } -func TestAuthBearer_RejectsWrongScheme(t *testing.T) { +func TestAuthenticate_RejectsWrongScheme(t *testing.T) { t.Parallel() cases := []struct { @@ -170,7 +182,7 @@ func TestAuthBearer_RejectsWrongScheme(t *testing.T) { t.Error("next handler should not be invoked for malformed Authorization header") }) - ts := httptest.NewServer(server.AuthBearer("s3cret", next)) + ts := httptest.NewServer(authMiddleware("s3cret", next)) t.Cleanup(ts.Close) req := newRequest(t, ts.URL+"/") @@ -196,7 +208,7 @@ func TestAuthBearer_RejectsWrongScheme(t *testing.T) { } } -func TestAuthBearer_HealthzBypassesAuth(t *testing.T) { +func TestAuthenticate_HealthzBypassesAuth(t *testing.T) { t.Parallel() cases := []struct { @@ -218,7 +230,7 @@ func TestAuthBearer_HealthzBypassesAuth(t *testing.T) { _, _ = io.WriteString(w, "ok") }) - ts := httptest.NewServer(server.AuthBearer("s3cret", next)) + ts := httptest.NewServer(authMiddleware("s3cret", next)) t.Cleanup(ts.Close) resp := httpGet(t, ts.URL+tc.path) // no Authorization header @@ -235,6 +247,55 @@ func TestAuthBearer_HealthzBypassesAuth(t *testing.T) { } } +func TestAuthenticate_StoresPrincipalInContext(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + token string + authHeader string + wantSubject string + }{ + {"none auth is anonymous", "", "", ""}, + {"static token subject", "s3cret", "Bearer s3cret", "static-token"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var got server.Principal + var found bool + next := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + got, found = server.PrincipalFrom(r.Context()) + }) + + ts := httptest.NewServer(authMiddleware(tc.token, next)) + t.Cleanup(ts.Close) + + req := newRequest(t, ts.URL+"/") + if tc.authHeader != "" { + req.Header.Set("Authorization", tc.authHeader) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + + defer func() { _ = resp.Body.Close() }() + + if !found { + t.Fatal("PrincipalFrom returned ok=false, want a Principal in context") + } + + if got.Subject != tc.wantSubject { + t.Errorf("Principal.Subject = %q, want %q", got.Subject, tc.wantSubject) + } + }) + } +} + func TestBearerToken(t *testing.T) { t.Parallel() diff --git a/internal/server/export_test.go b/internal/server/export_test.go index 12d7e6c..d90f576 100644 --- a/internal/server/export_test.go +++ b/internal/server/export_test.go @@ -27,8 +27,10 @@ func NewWithIntrospector( var ( Recoverer = recoverer Logging = logging - AuthBearer = authBearer + Authenticate = authenticate + NewStaticTokenAuth = newStaticTokenAuth BearerToken = bearerToken + PrincipalFrom = principalFrom Cors = cors NormalizeScanValue = normalizeScanValue WantsCSV = wantsCSV @@ -51,6 +53,8 @@ const ( type PreferDirective = preferDirective +type NoneAuth = noneAuth + func WriteProblem( w http.ResponseWriter, r *http.Request, diff --git a/internal/server/router.go b/internal/server/router.go index 1daad1e..9539bc8 100644 --- a/internal/server/router.go +++ b/internal/server/router.go @@ -12,7 +12,7 @@ func (s *Server) routes() http.Handler { mux.HandleFunc("DELETE /{table}", s.delete) // logging wraps recoverer so panics still produce an access-log line. - // cors sits outside authBearer so OPTIONS preflight, which carries no + // cors sits outside authenticate so OPTIONS preflight, which carries no // Authorization header, can be answered without tripping the auth gate. - return logging(recoverer(cors(s.corsOrigins, authBearer(s.authToken, mux)))) + return logging(recoverer(cors(s.corsOrigins, authenticate(s.authenticator, mux)))) } diff --git a/internal/server/server.go b/internal/server/server.go index 7a8dd42..ee7786e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -30,7 +30,7 @@ type Server struct { maxLimit int maxBodyBytes int64 readOnly bool - authToken string + authenticator Authenticator corsOrigins []string timeout time.Duration @@ -108,12 +108,23 @@ func newServer(cfg config.Config, db *sql.DB, intro schema.Introspector) (*Serve maxLimit: cfg.MaxLimit, maxBodyBytes: maxBodyBytes, readOnly: cfg.ReadOnly, - authToken: cfg.AuthToken, + authenticator: newAuthenticator(cfg), corsOrigins: cfg.CORSOrigins, timeout: cfg.Timeout, }, nil } +// newAuthenticator selects the request authenticator from the config. An empty +// AuthToken keeps the API fully open (noneAuth); a set token gates it behind +// the shared Bearer token (staticTokenAuth). +func newAuthenticator(cfg config.Config) Authenticator { + if cfg.AuthToken == "" { + return noneAuth{} + } + + return newStaticTokenAuth(cfg.AuthToken) +} + func (s *Server) Run(ctx context.Context) error { if err := s.Prepare(ctx); err != nil { return err From 1def0dadd4ce1d24ea06c4ff095fffc71d0ab9d0 Mon Sep 17 00:00:00 2001 From: Tetsuro Mikami Date: Thu, 2 Jul 2026 09:50:36 +0900 Subject: [PATCH 2/3] refactor(config): restructure auth settings into an auth block with mode --- README.md | 71 ++++++++++++++++++---------------- examples/adms.toml | 14 ++++--- examples/adms.yaml | 12 ++++-- internal/cli/cli.go | 19 ++++----- internal/cli/cli_test.go | 31 ++++++++------- internal/config/config.go | 49 +++++++++++++++++++++-- internal/config/config_test.go | 53 +++++++++++++++++++++++-- internal/config/file.go | 43 ++++++++++++-------- internal/server/cors_test.go | 2 +- internal/server/server.go | 12 +++--- internal/server/server_test.go | 2 +- internal/ui/ui.go | 2 +- internal/ui/ui_test.go | 2 +- 13 files changed, 213 insertions(+), 99 deletions(-) diff --git a/README.md b/README.md index 681b1eb..14f4714 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ PostgREST-style HTTP API for PostgreSQL and MySQL, plus an optional bundled admi [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![GitHub Sponsors](https://img.shields.io/github/sponsors/mickamy?label=sponsor&logo=github)](https://github.com/sponsors/mickamy) -> Status: the read / write API and the bundled admin UI both ship, including CSV / JSON export, keyboard shortcuts, an a11y pass, and a schema ER diagram. +> Status: the read / write API and the bundled admin UI both ship, including CSV / JSON export, keyboard shortcuts, an +> a11y pass, and a schema ER diagram. ## TL;DR @@ -330,16 +331,16 @@ Errors follow a PostgREST-shaped JSON envelope with adms-specific codes (prefixe } ``` -| HTTP | code | When | -|------|-------------------------|----------------------------------------| -| 400 | `ADMS_INVALID_FILTER` | Bad operator or value format | -| 400 | `ADMS_UNFILTERED_WRITE` | `PATCH` / `DELETE` without any filter | -| 400 | `ADMS_UNKNOWN_COLUMN` | Column name not in schema | +| HTTP | code | When | +|------|-------------------------|-----------------------------------------| +| 400 | `ADMS_INVALID_FILTER` | Bad operator or value format | +| 400 | `ADMS_UNFILTERED_WRITE` | `PATCH` / `DELETE` without any filter | +| 400 | `ADMS_UNKNOWN_COLUMN` | Column name not in schema | | 403 | `ADMS_READ_ONLY` | Write attempted while `read_only: true` | -| 404 | `ADMS_UNKNOWN_TABLE` | Table name not in (allowed) schema | -| 409 | `ADMS_CONFLICT` | DB-level unique / FK violation | -| 422 | `ADMS_INVALID_BODY` | JSON body fails column-type validation | -| 500 | `ADMS_INTERNAL` | Anything unexpected | +| 404 | `ADMS_UNKNOWN_TABLE` | Table name not in (allowed) schema | +| 409 | `ADMS_CONFLICT` | DB-level unique / FK violation | +| 422 | `ADMS_INVALID_BODY` | JSON body fails column-type validation | +| 500 | `ADMS_INTERNAL` | Anything unexpected | ### Schema endpoint @@ -447,7 +448,7 @@ interactive with vanilla `fetch` against the same HTTP API documented above. The UI calls the same HTTP API you would. Cross-origin calls between the two listeners are handled automatically — `adms` adds the UI's origin to the API's allowed origins, so you do not need to list it in `cors_origins`. When -`auth_token_env` names a populated env var, the UI carries that token on every request. +`auth.mode` is `static`, the UI carries the resolved token on every request. When `read_only: true`, the UI hides edit / insert / delete affordances. The UI does not introduce its own login flow — keep it behind your network or gateway. @@ -476,8 +477,8 @@ staging dashboards, demos, or anywhere writes must be impossible by construction Restrict which schemas (or tables) are exposed: ```yaml -allowed_schemas: [public, reporting] -allowed_tables: [users, posts, comments] +allowed_schemas: [ public, reporting ] +allowed_tables: [ users, posts, comments ] ``` Anything outside the allowlist is invisible — at `GET /`, at the per-table endpoints, and in the UI sidebar. @@ -485,14 +486,17 @@ Anything outside the allowlist is invisible — at `GET /`, at the per-table end ### Bearer token ```yaml -auth_token_env: ADMS_TOKEN +auth: + mode: static + static: + token_env: ADMS_TOKEN ``` -When set, `adms` reads the bearer token from the named environment variable and requires every request to include -`Authorization: Bearer `. The admin UI carries the token automatically (the resolved value is exposed via a -meta tag that an inline fetch wrapper picks up and attaches to every API-origin request). This is intentionally -simple — for OIDC / JWT, terminate auth at your gateway. The token value itself never appears in the config file, so -it does not leak into version control. +With `auth.mode: static`, `adms` reads the bearer token from the env var named by `static.token_env` and requires +every request to include `Authorization: Bearer `. The admin UI carries the token automatically (the resolved +value is exposed via a meta tag that an inline fetch wrapper picks up and attaches to every API-origin request). This +is intentionally simple — for OIDC / JWT, terminate auth at your gateway. The token value itself never appears in the +config file, so it does not leak into version control. `auth.mode` defaults to `none`, which leaves the API open. ### CORS @@ -540,20 +544,21 @@ dsn: "${ADMS_DSN}" The full set of fields, with defaults and meaning: -| Field | Default | Description | -|-------------------|------------------|------------------------------------------------------------| -| `driver` | _(required)_ | `postgres` or `mysql` | -| `dsn` | _(required)_ | Database connection string (prefer `${VAR}` expansion) | -| `listen` | `:7777` | API listen address | -| `read_only` | `false` | Reject all write methods with `403` | -| `allowed_schemas` | _(driver default)_ | Schemas to introspect | -| `allowed_tables` | _(all)_ | Table allowlist (empty means every introspected table) | -| `timeout` | `30s` | Startup operation timeout (DSN parsing, introspect, etc.) | -| `cors_origins` | _(none)_ | Allowed origins for CORS | -| `auth_token_env` | _(none)_ | Name of the env var holding a bearer token to require | -| `log_level` | `info` | `debug` / `info` / `warn` / `error` | -| `ui.enabled` | `false` | Mount the bundled admin UI | -| `ui.listen` | `:7778` | Listen address for the admin UI | +| Field | Default | Description | +|-------------------------|--------------------|---------------------------------------------------------------| +| `driver` | _(required)_ | `postgres` or `mysql` | +| `dsn` | _(required)_ | Database connection string (prefer `${VAR}` expansion) | +| `listen` | `:7777` | API listen address | +| `read_only` | `false` | Reject all write methods with `403` | +| `allowed_schemas` | _(driver default)_ | Schemas to introspect | +| `allowed_tables` | _(all)_ | Table allowlist (empty means every introspected table) | +| `timeout` | `30s` | Startup operation timeout (DSN parsing, introspect, etc.) | +| `cors_origins` | _(none)_ | Allowed origins for CORS | +| `auth.mode` | `none` | `none` (open) or `static` (require a bearer token) | +| `auth.static.token_env` | _(none)_ | Name of the env var holding the bearer token (`mode: static`) | +| `log_level` | `info` | `debug` / `info` / `warn` / `error` | +| `ui.enabled` | `false` | Mount the bundled admin UI | +| `ui.listen` | `:7778` | Listen address for the admin UI | TOML works the same way: diff --git a/examples/adms.toml b/examples/adms.toml index 0ba8e65..35d3324 100644 --- a/examples/adms.toml +++ b/examples/adms.toml @@ -9,13 +9,17 @@ allowed_tables = [] cors_origins = [] -# Name of an env var holding the bearer token; an empty string disables -# bearer auth. When ui.enabled is true, the UI auto-forwards the resolved -# token to its browser fetches via a meta tag. -auth_token_env = "" - log_level = "info" +# Authentication. mode defaults to "none" (fully open). Set mode to "static" +# and point static.token_env at an env var holding the bearer token to require +# it. When ui.enabled is true, the UI auto-forwards the resolved token to its +# browser fetches via a meta tag. +[auth] +mode = "none" +# [auth.static] +# token_env = "ADMS_TOKEN" + [ui] enabled = true listen = ":7778" diff --git a/examples/adms.yaml b/examples/adms.yaml index 0158f3d..316ce0b 100644 --- a/examples/adms.yaml +++ b/examples/adms.yaml @@ -10,10 +10,14 @@ allowed_tables: [] cors_origins: [] -# Name of an env var holding the bearer token; an empty string disables -# bearer auth. When ui.enabled is true, the UI auto-forwards the resolved -# token to its browser fetches via a meta tag. -auth_token_env: "" +# Authentication. mode defaults to "none" (fully open). Set mode to "static" +# and point static.token_env at an env var holding the bearer token to require +# it. When ui.enabled is true, the UI auto-forwards the resolved token to its +# browser fetches via a meta tag. +auth: + mode: none + # static: + # token_env: ADMS_TOKEN log_level: info diff --git a/internal/cli/cli.go b/internal/cli/cli.go index a76e7a3..1a17b4f 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -37,7 +37,7 @@ func Run(args []string, _, stderr io.Writer) int { return exit.Usage } - if err := resolveAuthToken(&cfg); err != nil { + if err := resolveAuth(&cfg); err != nil { fmt.Fprintf(stderr, "adms: %v\n", err) return exit.Usage @@ -170,21 +170,22 @@ func resolveConfigPath(args []string) (string, error) { } } -// resolveAuthToken fails fast if AuthTokenEnv is set but unresolved, so -// opting into auth cannot silently serve an open API. -func resolveAuthToken(cfg *config.Config) error { - if cfg.AuthTokenEnv == "" { +// resolveAuth reads the static bearer token from its env var when auth.mode is +// static. It fails fast if the variable is unset, so opting into static auth +// cannot silently serve an open API. Other modes need no startup resolution. +func resolveAuth(cfg *config.Config) error { + if cfg.Auth.Mode != config.AuthModeStatic { return nil } - tok := os.Getenv(cfg.AuthTokenEnv) + tok := os.Getenv(cfg.Auth.TokenEnv) if tok == "" { return fmt.Errorf( - "auth_token_env %q is set but the environment variable is empty or unset", - cfg.AuthTokenEnv) + "auth.static.token_env %q is set but the environment variable is empty or unset", + cfg.Auth.TokenEnv) } - cfg.AuthToken = tok + cfg.Auth.Token = tok return nil } diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 62e4449..62e84cb 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -96,10 +96,11 @@ func TestRun_PreConnectionErrors(t *testing.T) { }, { // Env name is unique to this test so parallel runs cannot race on it. - name: "auth_token_env points to unset variable", + name: "auth static token_env points to unset variable", args: func(t *testing.T) []string { p := filepath.Join(t.TempDir(), "adms.yaml") - body := "driver: postgres\ndsn: x\nauth_token_env: ADMS_AUTH_TOKEN_NOT_SET_IN_CLI_TEST\n" + body := "driver: postgres\ndsn: x\n" + + "auth:\n mode: static\n static:\n token_env: ADMS_AUTH_TOKEN_NOT_SET_IN_CLI_TEST\n" if err := os.WriteFile(p, []byte(body), 0o600); err != nil { t.Fatalf("write fixture: %v", err) } @@ -107,7 +108,7 @@ func TestRun_PreConnectionErrors(t *testing.T) { return []string{p} }, wantExit: exit.Usage, - wantStderr: "auth_token_env", + wantStderr: "auth.static.token_env", }, } @@ -134,20 +135,20 @@ func TestRun_ResolvesAuthTokenFromEnv(t *testing.T) { const envName = "ADMS_AUTH_TOKEN_RESOLVED_IN_CLI_TEST" // DSN points to a closed TCP port so pingDB fails fast (exit.Error) - // after resolveAuthToken runs, without needing a real database. + // after resolveAuth runs, without needing a real database. cases := []struct { - name string - authTokenEnvLine string - setenv func(t *testing.T) + name string + authBlock string + setenv func(t *testing.T) }{ { - name: "auth_token_env unset returns nil and run reaches ping", - authTokenEnvLine: "", - setenv: func(*testing.T) {}, + name: "auth mode none needs no resolution and run reaches ping", + authBlock: "", + setenv: func(*testing.T) {}, }, { - name: "auth_token_env points to populated env", - authTokenEnvLine: "auth_token_env: " + envName + "\n", + name: "auth static resolves token from populated env", + authBlock: "auth:\n mode: static\n static:\n token_env: " + envName + "\n", setenv: func(t *testing.T) { t.Setenv(envName, "tk") }, @@ -162,7 +163,7 @@ func TestRun_ResolvesAuthTokenFromEnv(t *testing.T) { body := "driver: postgres\n" + `dsn: "postgres://adms:adms@127.0.0.1:1/adms_test?sslmode=disable&connect_timeout=1"` + "\n" + - tc.authTokenEnvLine + + tc.authBlock + "timeout: 1s\n" if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatalf("write fixture: %v", err) @@ -175,8 +176,8 @@ func TestRun_ResolvesAuthTokenFromEnv(t *testing.T) { t.Fatalf("exit = %d, want %d (stderr=%q)", code, exit.Error, stderr.String()) } - if strings.Contains(stderr.String(), "auth_token_env") { - t.Errorf("stderr = %q, want auth_token_env NOT to appear (resolution should have succeeded)", + if strings.Contains(stderr.String(), "token_env") { + t.Errorf("stderr = %q, want token_env NOT to appear (resolution should have succeeded)", stderr.String()) } diff --git a/internal/config/config.go b/internal/config/config.go index a89ddd8..e8b1d4b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -29,8 +29,7 @@ type Config struct { Timeout time.Duration UI UIConfig CORSOrigins []string - AuthTokenEnv string - AuthToken string // Resolved at runtime from AuthTokenEnv by the cli; empty disables bearer auth. + Auth Auth LogLevel string DefaultLimit int MaxLimit int @@ -42,6 +41,23 @@ type UIConfig struct { Listen string } +// AuthMode selects how the server authenticates requests. +type AuthMode string + +const ( + AuthModeNone AuthMode = "none" + AuthModeStatic AuthMode = "static" +) + +// Auth holds the resolved authentication settings. Token is populated by the +// cli from the TokenEnv environment variable when Mode is static, and is empty +// otherwise. +type Auth struct { + Mode AuthMode + TokenEnv string + Token string +} + func Load(path string) (Config, error) { c, err := loadFile(path) if err != nil { @@ -137,6 +153,11 @@ func buildConfig(c config) (Config, error) { return Config{}, fmt.Errorf("invalid max_body_bytes %d: must be positive", maxBodyBytes) } + auth, err := buildAuth(c.Auth) + if err != nil { + return Config{}, err + } + return Config{ Driver: drv, DSN: c.DSN, @@ -150,7 +171,7 @@ func buildConfig(c config) (Config, error) { Listen: uiListen, }, CORSOrigins: c.CORSOrigins, - AuthTokenEnv: c.AuthTokenEnv, + Auth: auth, LogLevel: logLevel, DefaultLimit: defaultLimit, MaxLimit: maxLimit, @@ -158,6 +179,28 @@ func buildConfig(c config) (Config, error) { }, nil } +// buildAuth validates the auth block and returns the resolved settings. The +// static token itself is not read here; the cli resolves TokenEnv into Token +// at startup so the token value never lives in the config file. +func buildAuth(a authConfig) (Auth, error) { + mode := AuthMode(strings.ToLower(a.Mode)) + if a.Mode == "" { + mode = AuthModeNone + } + + switch mode { + case AuthModeNone, AuthModeStatic: + default: + return Auth{}, fmt.Errorf("invalid auth.mode %q (want none or static)", a.Mode) + } + + if mode == AuthModeStatic && a.Static.TokenEnv == "" { + return Auth{}, errors.New("auth.static.token_env is required when auth.mode is static") + } + + return Auth{Mode: mode, TokenEnv: a.Static.TokenEnv}, nil +} + func parseDriver(s string) (database.Driver, error) { switch strings.ToLower(strings.TrimSpace(s)) { case "postgres", "postgresql", "pg": diff --git a/internal/config/config_test.go b/internal/config/config_test.go index bc8ea0c..51d786e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -49,7 +49,10 @@ allowed_schemas: [public, internal] allowed_tables: [users, posts] timeout: 1m cors_origins: ["http://localhost:3000"] -auth_token_env: ADMS_TOKEN +auth: + mode: static + static: + token_env: ADMS_TOKEN log_level: debug ui: enabled: true @@ -63,7 +66,7 @@ ui: AllowedTables: []string{"users", "posts"}, Timeout: time.Minute, CORSOrigins: []string{"http://localhost:3000"}, - AuthTokenEnv: "ADMS_TOKEN", + Auth: config.Auth{Mode: config.AuthModeStatic, TokenEnv: "ADMS_TOKEN"}, LogLevel: "debug", UI: config.UIConfig{Enabled: true, Listen: ":9000"}, }, @@ -387,6 +390,43 @@ dsn: x log_level: verbose`, wantErr: `invalid log_level "verbose"`, }, + { + name: "auth mode static requires token_env", + filename: "adms.yaml", + body: `driver: postgres +dsn: x +auth: + mode: static`, + wantErr: "auth.static.token_env is required", + }, + { + name: "invalid auth mode rejected", + filename: "adms.yaml", + body: `driver: postgres +dsn: x +auth: + mode: bogus`, + wantErr: `invalid auth.mode "bogus"`, + }, + { + name: "auth mode is normalized to lower case", + filename: "adms.yaml", + body: `driver: postgres +dsn: x +auth: + mode: STATIC + static: + token_env: ADMS_TOKEN`, + want: config.Config{ + Driver: database.DriverPostgres, + DSN: "x", + Listen: config.DefaultListen, + Timeout: config.DefaultTimeout, + LogLevel: config.DefaultLogLevel, + UI: config.UIConfig{Listen: config.DefaultUIListen}, + Auth: config.Auth{Mode: config.AuthModeStatic, TokenEnv: "ADMS_TOKEN"}, + }, + }, { name: "whitespace around scalar string fields is normalized", filename: "adms.yaml", @@ -709,8 +749,13 @@ func assertConfigEqual(t *testing.T, got, want config.Config) { t.Errorf("CORSOrigins = %v, want %v", got.CORSOrigins, want.CORSOrigins) } - if got.AuthTokenEnv != want.AuthTokenEnv { - t.Errorf("AuthTokenEnv = %q, want %q", got.AuthTokenEnv, want.AuthTokenEnv) + wantAuth := want.Auth + if wantAuth.Mode == "" { + wantAuth.Mode = config.AuthModeNone + } + + if got.Auth != wantAuth { + t.Errorf("Auth = %+v, want %+v", got.Auth, wantAuth) } if got.LogLevel != want.LogLevel { diff --git a/internal/config/file.go b/internal/config/file.go index 52faf17..58d0e61 100644 --- a/internal/config/file.go +++ b/internal/config/file.go @@ -14,20 +14,20 @@ import ( ) type config struct { - Driver string `toml:"driver" yaml:"driver"` - DSN string `toml:"dsn" yaml:"dsn"` - Listen string `toml:"listen" yaml:"listen"` - ReadOnly bool `toml:"read_only" yaml:"read_only"` - AllowedSchemas []string `toml:"allowed_schemas" yaml:"allowed_schemas"` - AllowedTables []string `toml:"allowed_tables" yaml:"allowed_tables"` - Timeout string `toml:"timeout" yaml:"timeout"` - UI uiConfig `toml:"ui" yaml:"ui"` - CORSOrigins []string `toml:"cors_origins" yaml:"cors_origins"` - AuthTokenEnv string `toml:"auth_token_env" yaml:"auth_token_env"` - LogLevel string `toml:"log_level" yaml:"log_level"` - DefaultLimit *int `toml:"default_limit" yaml:"default_limit"` - MaxLimit *int `toml:"max_limit" yaml:"max_limit"` - MaxBodyBytes *int64 `toml:"max_body_bytes" yaml:"max_body_bytes"` + Driver string `toml:"driver" yaml:"driver"` + DSN string `toml:"dsn" yaml:"dsn"` + Listen string `toml:"listen" yaml:"listen"` + ReadOnly bool `toml:"read_only" yaml:"read_only"` + AllowedSchemas []string `toml:"allowed_schemas" yaml:"allowed_schemas"` + AllowedTables []string `toml:"allowed_tables" yaml:"allowed_tables"` + Timeout string `toml:"timeout" yaml:"timeout"` + UI uiConfig `toml:"ui" yaml:"ui"` + CORSOrigins []string `toml:"cors_origins" yaml:"cors_origins"` + Auth authConfig `toml:"auth" yaml:"auth"` + LogLevel string `toml:"log_level" yaml:"log_level"` + DefaultLimit *int `toml:"default_limit" yaml:"default_limit"` + MaxLimit *int `toml:"max_limit" yaml:"max_limit"` + MaxBodyBytes *int64 `toml:"max_body_bytes" yaml:"max_body_bytes"` } type uiConfig struct { @@ -35,6 +35,15 @@ type uiConfig struct { Listen string `toml:"listen" yaml:"listen"` } +type authConfig struct { + Mode string `toml:"mode" yaml:"mode"` + Static staticAuthConfig `toml:"static" yaml:"static"` +} + +type staticAuthConfig struct { + TokenEnv string `toml:"token_env" yaml:"token_env"` +} + func loadFile(path string) (config, error) { // G304 is intentional: path is supplied by the operator via the CLI. data, err := os.ReadFile(path) //nolint:gosec @@ -92,7 +101,8 @@ func expandEnv(c *config) { c.DSN = os.ExpandEnv(c.DSN) c.Listen = os.ExpandEnv(c.Listen) c.Timeout = os.ExpandEnv(c.Timeout) - c.AuthTokenEnv = os.ExpandEnv(c.AuthTokenEnv) + c.Auth.Mode = os.ExpandEnv(c.Auth.Mode) + c.Auth.Static.TokenEnv = os.ExpandEnv(c.Auth.Static.TokenEnv) c.LogLevel = os.ExpandEnv(c.LogLevel) c.UI.Listen = os.ExpandEnv(c.UI.Listen) @@ -117,7 +127,8 @@ func normalize(c *config) { c.DSN = strings.TrimSpace(c.DSN) c.Listen = strings.TrimSpace(c.Listen) c.Timeout = strings.TrimSpace(c.Timeout) - c.AuthTokenEnv = strings.TrimSpace(c.AuthTokenEnv) + c.Auth.Mode = strings.TrimSpace(c.Auth.Mode) + c.Auth.Static.TokenEnv = strings.TrimSpace(c.Auth.Static.TokenEnv) c.LogLevel = strings.TrimSpace(c.LogLevel) c.UI.Listen = strings.TrimSpace(c.UI.Listen) diff --git a/internal/server/cors_test.go b/internal/server/cors_test.go index 38557bb..560edc2 100644 --- a/internal/server/cors_test.go +++ b/internal/server/cors_test.go @@ -269,7 +269,7 @@ func TestServerAppliesCORSOrigins(t *testing.T) { Timeout: time.Second, DefaultLimit: 100, MaxLimit: 1000, - AuthToken: token, + Auth: config.Auth{Mode: config.AuthModeStatic, Token: token}, CORSOrigins: []string{corsTestOrigin}, }, stubDB, diff --git a/internal/server/server.go b/internal/server/server.go index ee7786e..3b8d3e4 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -114,15 +114,15 @@ func newServer(cfg config.Config, db *sql.DB, intro schema.Introspector) (*Serve }, nil } -// newAuthenticator selects the request authenticator from the config. An empty -// AuthToken keeps the API fully open (noneAuth); a set token gates it behind -// the shared Bearer token (staticTokenAuth). +// newAuthenticator selects the request authenticator from the config. auth.mode +// static gates the API behind the resolved shared Bearer token +// (staticTokenAuth); any other mode keeps it fully open (noneAuth). func newAuthenticator(cfg config.Config) Authenticator { - if cfg.AuthToken == "" { - return noneAuth{} + if cfg.Auth.Mode == config.AuthModeStatic { + return newStaticTokenAuth(cfg.Auth.Token) } - return newStaticTokenAuth(cfg.AuthToken) + return noneAuth{} } func (s *Server) Run(ctx context.Context) error { diff --git a/internal/server/server_test.go b/internal/server/server_test.go index aa56554..4c64cd8 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -602,7 +602,7 @@ func TestServerAppliesAuthToken(t *testing.T) { Timeout: time.Second, DefaultLimit: 100, MaxLimit: 1000, - AuthToken: token, + Auth: config.Auth{Mode: config.AuthModeStatic, Token: token}, }, stubDB, stubIntrospector{}, diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 9b28d98..dc90c58 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -71,7 +71,7 @@ func New(cfg config.Config, sch schema.Schema, apiOrigin string) (*Server, error return &Server{ addr: cfg.UI.Listen, apiOrigin: apiOrigin, - authToken: cfg.AuthToken, + authToken: cfg.Auth.Token, schema: sch, readOnly: cfg.ReadOnly, tmpl: tmpl, diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go index 631be35..1f5837d 100644 --- a/internal/ui/ui_test.go +++ b/internal/ui/ui_test.go @@ -1383,7 +1383,7 @@ func newTestUIServerWithToken(t *testing.T, sch schema.Schema, token string) *ht t.Helper() srv, err := ui.New( - config.Config{UI: config.UIConfig{Listen: ":0"}, AuthToken: token}, + config.Config{UI: config.UIConfig{Listen: ":0"}, Auth: config.Auth{Token: token}}, sch, apiOrigin, ) if err != nil { From d93295ec88dd80bd377c13220fb903dab548f1c0 Mon Sep 17 00:00:00 2001 From: Tetsuro Mikami Date: Thu, 2 Jul 2026 11:28:37 +0900 Subject: [PATCH 3/3] feat(auth): add OIDC/JWT authentication mode --- README.md | 56 ++++-- examples/adms.toml | 12 +- examples/adms.yaml | 12 +- go.mod | 3 + go.sum | 6 + internal/config/config.go | 35 +++- internal/config/config_test.go | 48 +++++ internal/config/file.go | 13 ++ internal/server/export_test.go | 2 + internal/server/oidc.go | 100 +++++++++++ internal/server/oidc_test.go | 313 +++++++++++++++++++++++++++++++++ internal/server/server.go | 33 +++- 12 files changed, 596 insertions(+), 37 deletions(-) create mode 100644 internal/server/oidc.go create mode 100644 internal/server/oidc_test.go diff --git a/README.md b/README.md index 14f4714..beeb9fd 100644 --- a/README.md +++ b/README.md @@ -495,8 +495,27 @@ auth: With `auth.mode: static`, `adms` reads the bearer token from the env var named by `static.token_env` and requires every request to include `Authorization: Bearer `. The admin UI carries the token automatically (the resolved value is exposed via a meta tag that an inline fetch wrapper picks up and attaches to every API-origin request). This -is intentionally simple — for OIDC / JWT, terminate auth at your gateway. The token value itself never appears in the -config file, so it does not leak into version control. `auth.mode` defaults to `none`, which leaves the API open. +is intentionally simple — a single shared secret. The token value itself never appears in the config file, so it does +not leak into version control. `auth.mode` defaults to `none`, which leaves the API open. + +### OIDC / JWT + +```yaml +auth: + mode: oidc + oidc: + issuer: https://your-tenant.auth0.com/ + audience: adms + roles_claim: https://adms/roles # optional +``` + +With `auth.mode: oidc`, `adms` validates the `Authorization: Bearer ` on every request against the issuer's +published keys. The JWKS is fetched from the issuer's OIDC discovery document at startup and refreshed automatically on +key rotation, so no key material lives in the config. The signature, issuer, audience, and expiry are all checked; a +token that fails any of them is rejected with `401`. `roles_claim` names the claim carrying the caller's roles (a JSON +array or a space-separated string); it is surfaced on the request principal for future role-based authorization. An +unreachable issuer fails startup rather than silently serving an open API. adms itself has no login flow — obtain +tokens from your identity provider (Auth0, Cognito, Keycloak, …) and present them as bearer tokens. ### CORS @@ -544,21 +563,24 @@ dsn: "${ADMS_DSN}" The full set of fields, with defaults and meaning: -| Field | Default | Description | -|-------------------------|--------------------|---------------------------------------------------------------| -| `driver` | _(required)_ | `postgres` or `mysql` | -| `dsn` | _(required)_ | Database connection string (prefer `${VAR}` expansion) | -| `listen` | `:7777` | API listen address | -| `read_only` | `false` | Reject all write methods with `403` | -| `allowed_schemas` | _(driver default)_ | Schemas to introspect | -| `allowed_tables` | _(all)_ | Table allowlist (empty means every introspected table) | -| `timeout` | `30s` | Startup operation timeout (DSN parsing, introspect, etc.) | -| `cors_origins` | _(none)_ | Allowed origins for CORS | -| `auth.mode` | `none` | `none` (open) or `static` (require a bearer token) | -| `auth.static.token_env` | _(none)_ | Name of the env var holding the bearer token (`mode: static`) | -| `log_level` | `info` | `debug` / `info` / `warn` / `error` | -| `ui.enabled` | `false` | Mount the bundled admin UI | -| `ui.listen` | `:7778` | Listen address for the admin UI | +| Field | Default | Description | +|-------------------------|--------------------|----------------------------------------------------------------| +| `driver` | _(required)_ | `postgres` or `mysql` | +| `dsn` | _(required)_ | Database connection string (prefer `${VAR}` expansion) | +| `listen` | `:7777` | API listen address | +| `read_only` | `false` | Reject all write methods with `403` | +| `allowed_schemas` | _(driver default)_ | Schemas to introspect | +| `allowed_tables` | _(all)_ | Table allowlist (empty means every introspected table) | +| `timeout` | `30s` | Startup operation timeout (DSN parsing, introspect, etc.) | +| `cors_origins` | _(none)_ | Allowed origins for CORS | +| `auth.mode` | `none` | `none` (open), `static` (shared bearer token), or `oidc` (JWT) | +| `auth.static.token_env` | _(none)_ | Name of the env var holding the bearer token (`mode: static`) | +| `auth.oidc.issuer` | _(none)_ | OIDC issuer URL for discovery / JWKS (`mode: oidc`) | +| `auth.oidc.audience` | _(none)_ | Expected `aud` claim (`mode: oidc`) | +| `auth.oidc.roles_claim` | _(none)_ | Claim carrying the caller's roles (`mode: oidc`, optional) | +| `log_level` | `info` | `debug` / `info` / `warn` / `error` | +| `ui.enabled` | `false` | Mount the bundled admin UI | +| `ui.listen` | `:7778` | Listen address for the admin UI | TOML works the same way: diff --git a/examples/adms.toml b/examples/adms.toml index 35d3324..13c660c 100644 --- a/examples/adms.toml +++ b/examples/adms.toml @@ -11,14 +11,18 @@ cors_origins = [] log_level = "info" -# Authentication. mode defaults to "none" (fully open). Set mode to "static" -# and point static.token_env at an env var holding the bearer token to require -# it. When ui.enabled is true, the UI auto-forwards the resolved token to its -# browser fetches via a meta tag. +# Authentication. mode defaults to "none" (fully open). +# static: point static.token_env at an env var holding a shared bearer token. +# When ui.enabled is true, the UI auto-forwards the resolved token. +# oidc: validate JWTs against the issuer's JWKS (audience / expiry / signature). [auth] mode = "none" # [auth.static] # token_env = "ADMS_TOKEN" +# [auth.oidc] +# issuer = "https://your-tenant.auth0.com/" +# audience = "adms" +# roles_claim = "https://adms/roles" [ui] enabled = true diff --git a/examples/adms.yaml b/examples/adms.yaml index 316ce0b..53afa22 100644 --- a/examples/adms.yaml +++ b/examples/adms.yaml @@ -10,14 +10,18 @@ allowed_tables: [] cors_origins: [] -# Authentication. mode defaults to "none" (fully open). Set mode to "static" -# and point static.token_env at an env var holding the bearer token to require -# it. When ui.enabled is true, the UI auto-forwards the resolved token to its -# browser fetches via a meta tag. +# Authentication. mode defaults to "none" (fully open). +# static: point static.token_env at an env var holding a shared bearer token. +# When ui.enabled is true, the UI auto-forwards the resolved token. +# oidc: validate JWTs against the issuer's JWKS (audience / expiry / signature). auth: mode: none # static: # token_env: ADMS_TOKEN + # oidc: + # issuer: https://your-tenant.auth0.com/ + # audience: adms + # roles_claim: https://adms/roles log_level: info diff --git a/go.mod b/go.mod index 6ad6fea..fc5d9c2 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.26.3 require ( github.com/BurntSushi/toml v1.6.0 + github.com/coreos/go-oidc/v3 v3.19.0 github.com/go-sql-driver/mysql v1.10.0 github.com/jackc/pgx/v5 v5.9.2 gopkg.in/yaml.v3 v3.0.1 @@ -11,11 +12,13 @@ require ( require ( filippo.io/edwards25519 v1.2.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/kr/text v0.2.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.17.0 // indirect golang.org/x/text v0.29.0 // indirect ) diff --git a/go.sum b/go.sum index 0dad83b..376da37 100644 --- a/go.sum +++ b/go.sum @@ -2,10 +2,14 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/coreos/go-oidc/v3 v3.19.0 h1:F/xyOi3x1UnG1U27YVnM1N6bHiL1K2upi6U/0qr8r+I= +github.com/coreos/go-oidc/v3 v3.19.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -29,6 +33,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= diff --git a/internal/config/config.go b/internal/config/config.go index e8b1d4b..040e96e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -47,6 +47,7 @@ type AuthMode string const ( AuthModeNone AuthMode = "none" AuthModeStatic AuthMode = "static" + AuthModeOIDC AuthMode = "oidc" ) // Auth holds the resolved authentication settings. Token is populated by the @@ -56,6 +57,16 @@ type Auth struct { Mode AuthMode TokenEnv string Token string + OIDC OIDC +} + +// OIDC holds the settings for validating OIDC/JWT bearer tokens. The JWKS is +// fetched from the issuer's discovery document, so no key material lives in the +// config. +type OIDC struct { + Issuer string + Audience string + RolesClaim string } func Load(path string) (Config, error) { @@ -189,16 +200,34 @@ func buildAuth(a authConfig) (Auth, error) { } switch mode { - case AuthModeNone, AuthModeStatic: + case AuthModeNone, AuthModeStatic, AuthModeOIDC: default: - return Auth{}, fmt.Errorf("invalid auth.mode %q (want none or static)", a.Mode) + return Auth{}, fmt.Errorf("invalid auth.mode %q (want none, static, or oidc)", a.Mode) } if mode == AuthModeStatic && a.Static.TokenEnv == "" { return Auth{}, errors.New("auth.static.token_env is required when auth.mode is static") } - return Auth{Mode: mode, TokenEnv: a.Static.TokenEnv}, nil + if mode == AuthModeOIDC { + if a.OIDC.Issuer == "" { + return Auth{}, errors.New("auth.oidc.issuer is required when auth.mode is oidc") + } + + if a.OIDC.Audience == "" { + return Auth{}, errors.New("auth.oidc.audience is required when auth.mode is oidc") + } + } + + return Auth{ + Mode: mode, + TokenEnv: a.Static.TokenEnv, + OIDC: OIDC{ + Issuer: a.OIDC.Issuer, + Audience: a.OIDC.Audience, + RolesClaim: a.OIDC.RolesClaim, + }, + }, nil } func parseDriver(s string) (database.Driver, error) { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 51d786e..55595d9 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -427,6 +427,54 @@ auth: Auth: config.Auth{Mode: config.AuthModeStatic, TokenEnv: "ADMS_TOKEN"}, }, }, + { + name: "auth mode oidc requires issuer", + filename: "adms.yaml", + body: `driver: postgres +dsn: x +auth: + mode: oidc`, + wantErr: "auth.oidc.issuer is required", + }, + { + name: "auth mode oidc requires audience", + filename: "adms.yaml", + body: `driver: postgres +dsn: x +auth: + mode: oidc + oidc: + issuer: https://issuer.example.com/`, + wantErr: "auth.oidc.audience is required", + }, + { + name: "auth mode oidc full parses", + filename: "adms.yaml", + body: `driver: postgres +dsn: x +auth: + mode: oidc + oidc: + issuer: https://issuer.example.com/ + audience: adms + roles_claim: https://adms/roles`, + want: config.Config{ + Driver: database.DriverPostgres, + DSN: "x", + Listen: config.DefaultListen, + Timeout: config.DefaultTimeout, + LogLevel: config.DefaultLogLevel, + UI: config.UIConfig{Listen: config.DefaultUIListen}, + Auth: config.Auth{ + Mode: config.AuthModeOIDC, + OIDC: config.OIDC{ + Issuer: "https://issuer.example.com/", + Audience: "adms", + RolesClaim: "https://adms/roles", + }, + }, + }, + }, { name: "whitespace around scalar string fields is normalized", filename: "adms.yaml", diff --git a/internal/config/file.go b/internal/config/file.go index 58d0e61..a0dd7bc 100644 --- a/internal/config/file.go +++ b/internal/config/file.go @@ -38,12 +38,19 @@ type uiConfig struct { type authConfig struct { Mode string `toml:"mode" yaml:"mode"` Static staticAuthConfig `toml:"static" yaml:"static"` + OIDC oidcConfig `toml:"oidc" yaml:"oidc"` } type staticAuthConfig struct { TokenEnv string `toml:"token_env" yaml:"token_env"` } +type oidcConfig struct { + Issuer string `toml:"issuer" yaml:"issuer"` + Audience string `toml:"audience" yaml:"audience"` + RolesClaim string `toml:"roles_claim" yaml:"roles_claim"` +} + func loadFile(path string) (config, error) { // G304 is intentional: path is supplied by the operator via the CLI. data, err := os.ReadFile(path) //nolint:gosec @@ -103,6 +110,9 @@ func expandEnv(c *config) { c.Timeout = os.ExpandEnv(c.Timeout) c.Auth.Mode = os.ExpandEnv(c.Auth.Mode) c.Auth.Static.TokenEnv = os.ExpandEnv(c.Auth.Static.TokenEnv) + c.Auth.OIDC.Issuer = os.ExpandEnv(c.Auth.OIDC.Issuer) + c.Auth.OIDC.Audience = os.ExpandEnv(c.Auth.OIDC.Audience) + c.Auth.OIDC.RolesClaim = os.ExpandEnv(c.Auth.OIDC.RolesClaim) c.LogLevel = os.ExpandEnv(c.LogLevel) c.UI.Listen = os.ExpandEnv(c.UI.Listen) @@ -129,6 +139,9 @@ func normalize(c *config) { c.Timeout = strings.TrimSpace(c.Timeout) c.Auth.Mode = strings.TrimSpace(c.Auth.Mode) c.Auth.Static.TokenEnv = strings.TrimSpace(c.Auth.Static.TokenEnv) + c.Auth.OIDC.Issuer = strings.TrimSpace(c.Auth.OIDC.Issuer) + c.Auth.OIDC.Audience = strings.TrimSpace(c.Auth.OIDC.Audience) + c.Auth.OIDC.RolesClaim = strings.TrimSpace(c.Auth.OIDC.RolesClaim) c.LogLevel = strings.TrimSpace(c.LogLevel) c.UI.Listen = strings.TrimSpace(c.UI.Listen) diff --git a/internal/server/export_test.go b/internal/server/export_test.go index d90f576..579e45b 100644 --- a/internal/server/export_test.go +++ b/internal/server/export_test.go @@ -29,6 +29,8 @@ var ( Logging = logging Authenticate = authenticate NewStaticTokenAuth = newStaticTokenAuth + NewOIDCAuth = newOIDCAuth + ExtractRoles = extractRoles BearerToken = bearerToken PrincipalFrom = principalFrom Cors = cors diff --git a/internal/server/oidc.go b/internal/server/oidc.go new file mode 100644 index 0000000..b83b6d1 --- /dev/null +++ b/internal/server/oidc.go @@ -0,0 +1,100 @@ +package server + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + + "github.com/mickamy/adms/internal/config" +) + +var _ Authenticator = oidcAuth{} + +// oidcDiscoveryTimeout bounds both the startup discovery round-trip and every +// later JWKS refresh. It is enforced via the HTTP client rather than a +// cancelable context because go-oidc retains the provider's context for +// background key rotation, so that context must outlive startup. +const oidcDiscoveryTimeout = 10 * time.Second + +// oidcAuth validates OIDC/JWT bearer tokens. The verifier checks the +// signature (against the issuer's JWKS), issuer, audience, and expiry; roles +// are read from the configured claim. +type oidcAuth struct { + verifier *oidc.IDTokenVerifier + rolesClaim string +} + +func newOIDCAuth(cfg config.OIDC) (oidcAuth, error) { + client := &http.Client{Timeout: oidcDiscoveryTimeout} + ctx := oidc.ClientContext(context.Background(), client) + + provider, err := oidc.NewProvider(ctx, cfg.Issuer) + if err != nil { + return oidcAuth{}, fmt.Errorf("oidc: discover issuer %q: %w", cfg.Issuer, err) + } + + verifier := provider.Verifier(&oidc.Config{ClientID: cfg.Audience}) + + return oidcAuth{verifier: verifier, rolesClaim: cfg.RolesClaim}, nil +} + +func (a oidcAuth) Authenticate(r *http.Request) (Principal, error) { + raw, ok := bearerToken(r.Header.Get("Authorization")) + if !ok { + return Principal{}, &authError{ + wwwAuthenticate: `Bearer realm="adms", error="invalid_request"`, + detail: "request requires a Bearer token in the Authorization header", + } + } + + tok, err := a.verifier.Verify(r.Context(), raw) + if err != nil { + return Principal{}, &authError{ + wwwAuthenticate: `Bearer realm="adms", error="invalid_token"`, + detail: "the Authorization token is invalid", + } + } + + var claims map[string]any + if err := tok.Claims(&claims); err != nil { + return Principal{}, &authError{ + wwwAuthenticate: `Bearer realm="adms", error="invalid_token"`, + detail: "the token claims could not be parsed", + } + } + + return Principal{ + Subject: tok.Subject, + Roles: extractRoles(claims, a.rolesClaim), + Claims: claims, + }, nil +} + +// extractRoles reads the roles claim, accepting either a JSON array of strings +// or a single space-separated string (both are common in the wild). An empty +// claim name, a missing claim, or an unexpected shape yields no roles. +func extractRoles(claims map[string]any, claimName string) []string { + if claimName == "" { + return nil + } + + switch v := claims[claimName].(type) { + case []any: + roles := make([]string, 0, len(v)) + for _, e := range v { + if s, ok := e.(string); ok && s != "" { + roles = append(roles, s) + } + } + + return roles + case string: + return strings.Fields(v) + default: + return nil + } +} diff --git a/internal/server/oidc_test.go b/internal/server/oidc_test.go new file mode 100644 index 0000000..61c720a --- /dev/null +++ b/internal/server/oidc_test.go @@ -0,0 +1,313 @@ +package server_test + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/mickamy/adms/internal/config" + "github.com/mickamy/adms/internal/server" +) + +func TestExtractRoles(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + claims map[string]any + claim string + want []string + }{ + {"empty claim name", map[string]any{"roles": []any{"a"}}, "", nil}, + {"missing claim", map[string]any{"other": 1}, "roles", nil}, + {"array of strings", map[string]any{"roles": []any{"viewer", "admin"}}, "roles", []string{"viewer", "admin"}}, + { + "array drops non-strings and empties", + map[string]any{"roles": []any{"viewer", 1, "", "admin"}}, "roles", + []string{"viewer", "admin"}, + }, + {"space-separated string", map[string]any{"scope": "viewer admin"}, "scope", []string{"viewer", "admin"}}, + {"wrong type yields nil", map[string]any{"roles": 42}, "roles", nil}, + {"empty array yields empty", map[string]any{"roles": []any{}}, "roles", []string{}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := server.ExtractRoles(tc.claims, tc.claim) + if !equalStringSlice(got, tc.want) { + t.Errorf("ExtractRoles(%v, %q) = %v, want %v", tc.claims, tc.claim, got, tc.want) + } + }) + } +} + +func TestOIDCAuth_Verification(t *testing.T) { + t.Parallel() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate key: %v", err) + } + + idp := newMockIDP(t, key) + + auth, err := server.NewOIDCAuth(config.OIDC{ + Issuer: idp.URL, + Audience: "adms", + RolesClaim: "roles", + }) + if err != nil { + t.Fatalf("NewOIDCAuth: %v", err) + } + + t.Run("valid token yields principal with roles array", func(t *testing.T) { + t.Parallel() + + claims := baseClaims(idp.URL) + claims["roles"] = []any{"viewer", "support"} + token := signJWT(t, key, claims) + + p := requireAuthenticated(t, auth, token) + if p.Subject != "user-123" { + t.Errorf("Subject = %q, want %q", p.Subject, "user-123") + } + + if !equalStringSlice(p.Roles, []string{"viewer", "support"}) { + t.Errorf("Roles = %v, want [viewer support]", p.Roles) + } + }) + + t.Run("roles from space-separated claim", func(t *testing.T) { + t.Parallel() + + claims := baseClaims(idp.URL) + claims["roles"] = "viewer support" + token := signJWT(t, key, claims) + + p := requireAuthenticated(t, auth, token) + if !equalStringSlice(p.Roles, []string{"viewer", "support"}) { + t.Errorf("Roles = %v, want [viewer support]", p.Roles) + } + }) + + t.Run("expired token is rejected", func(t *testing.T) { + t.Parallel() + + claims := baseClaims(idp.URL) + claims["exp"] = time.Now().Add(-time.Hour).Unix() + token := signJWT(t, key, claims) + + requireRejected(t, auth, token, `error="invalid_token"`) + }) + + t.Run("wrong audience is rejected", func(t *testing.T) { + t.Parallel() + + claims := baseClaims(idp.URL) + claims["aud"] = "someone-else" + token := signJWT(t, key, claims) + + requireRejected(t, auth, token, `error="invalid_token"`) + }) + + t.Run("tampered signature is rejected", func(t *testing.T) { + t.Parallel() + + other, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate key: %v", err) + } + + // Signed by a key whose public half the IdP does not publish. + token := signJWT(t, other, baseClaims(idp.URL)) + + requireRejected(t, auth, token, `error="invalid_token"`) + }) + + t.Run("missing header is rejected with invalid_request", func(t *testing.T) { + t.Parallel() + + requireRejected(t, auth, "", `error="invalid_request"`) + }) +} + +func TestNewOIDCAuth_UnreachableIssuerFails(t *testing.T) { + t.Parallel() + + _, err := server.NewOIDCAuth(config.OIDC{ + Issuer: "http://127.0.0.1:1/does-not-exist", + Audience: "adms", + }) + if err == nil { + t.Fatal("NewOIDCAuth error = nil, want a discovery failure (fail-closed)") + } +} + +// requireAuthenticated drives the token through the authenticate middleware and +// returns the Principal the next handler saw. The handler hands the Principal +// back over a buffered channel (not the response body) so the value crosses the +// goroutine boundary with proper synchronization and no JSON round-trip. +func requireAuthenticated(t *testing.T, auth server.Authenticator, token string) server.Principal { + t.Helper() + + got := make(chan server.Principal, 1) + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + p, ok := server.PrincipalFrom(r.Context()) + if !ok { + t.Error("PrincipalFrom returned ok=false in next handler") + } + + got <- p + w.WriteHeader(http.StatusOK) + }) + + ts := httptest.NewServer(server.Authenticate(auth, next)) + t.Cleanup(ts.Close) + + req := newRequest(t, ts.URL+"/") + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200 (token should authenticate)", resp.StatusCode) + } + + return <-got +} + +// requireRejected drives the token (empty means no Authorization header) through +// the middleware and asserts a 401 carrying wantChallenge in WWW-Authenticate. +func requireRejected(t *testing.T, auth server.Authenticator, token, wantChallenge string) { + t.Helper() + + next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + t.Error("next handler should not run for a rejected token") + }) + + ts := httptest.NewServer(server.Authenticate(auth, next)) + t.Cleanup(ts.Close) + + req := newRequest(t, ts.URL+"/") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", resp.StatusCode) + } + + if got := resp.Header.Get("WWW-Authenticate"); !strings.Contains(got, wantChallenge) { + t.Errorf("WWW-Authenticate = %q, want to contain %q", got, wantChallenge) + } +} + +// newMockIDP serves the minimal OIDC discovery document and JWKS that go-oidc +// needs to verify RS256 tokens signed by key. +func newMockIDP(t *testing.T, key *rsa.PrivateKey) *httptest.Server { + t.Helper() + + mux := http.NewServeMux() + ts := httptest.NewServer(mux) + t.Cleanup(ts.Close) + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "issuer": ts.URL, + "jwks_uri": ts.URL + "/jwks", + "id_token_signing_alg_values_supported": []string{"RS256"}, + }) + }) + + mux.HandleFunc("/jwks", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "keys": []map[string]any{{ + "kty": "RSA", + "use": "sig", + "alg": "RS256", + "kid": "test", + "n": base64.RawURLEncoding.EncodeToString(key.N.Bytes()), + "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.E)).Bytes()), + }}, + }) + }) + + return ts +} + +func baseClaims(issuer string) map[string]any { + return map[string]any{ + "iss": issuer, + "sub": "user-123", + "aud": "adms", + "iat": time.Now().Unix(), + "exp": time.Now().Add(time.Hour).Unix(), + } +} + +// signJWT builds a compact RS256 JWS with a static "test" kid so the mock IdP's +// JWKS can key on it. +func signJWT(t *testing.T, key *rsa.PrivateKey, claims map[string]any) string { + t.Helper() + + header, err := json.Marshal(map[string]any{"alg": "RS256", "kid": "test", "typ": "JWT"}) + if err != nil { + t.Fatalf("marshal header: %v", err) + } + + payload, err := json.Marshal(claims) + if err != nil { + t.Fatalf("marshal claims: %v", err) + } + + signingInput := base64.RawURLEncoding.EncodeToString(header) + "." + + base64.RawURLEncoding.EncodeToString(payload) + + digest := sha256.Sum256([]byte(signingInput)) + + sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:]) + if err != nil { + t.Fatalf("sign: %v", err) + } + + return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig) +} + +func equalStringSlice(a, b []string) bool { + if len(a) != len(b) { + return false + } + + for i := range a { + if a[i] != b[i] { + return false + } + } + + return true +} diff --git a/internal/server/server.go b/internal/server/server.go index 3b8d3e4..1459b13 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -97,6 +97,11 @@ func newServer(cfg config.Config, db *sql.DB, intro schema.Introspector) (*Serve return nil, fmt.Errorf("server: %w", err) } + auth, err := newAuthenticator(cfg.Auth) + if err != nil { + return nil, fmt.Errorf("server: %w", err) + } + return &Server{ addr: cfg.Listen, db: db, @@ -108,21 +113,31 @@ func newServer(cfg config.Config, db *sql.DB, intro schema.Introspector) (*Serve maxLimit: cfg.MaxLimit, maxBodyBytes: maxBodyBytes, readOnly: cfg.ReadOnly, - authenticator: newAuthenticator(cfg), + authenticator: auth, corsOrigins: cfg.CORSOrigins, timeout: cfg.Timeout, }, nil } -// newAuthenticator selects the request authenticator from the config. auth.mode -// static gates the API behind the resolved shared Bearer token -// (staticTokenAuth); any other mode keeps it fully open (noneAuth). -func newAuthenticator(cfg config.Config) Authenticator { - if cfg.Auth.Mode == config.AuthModeStatic { - return newStaticTokenAuth(cfg.Auth.Token) +// newAuthenticator selects the request authenticator from the auth config. +// static gates the API behind the resolved shared Bearer token; oidc validates +// JWTs against the issuer (discovering the JWKS now, so a bad issuer fails +// startup rather than every request); any other mode keeps it fully open. +func newAuthenticator(auth config.Auth) (Authenticator, error) { + switch auth.Mode { + case config.AuthModeNone, "": + // The empty zero value means "unset", which is open — same as none. + return noneAuth{}, nil + case config.AuthModeStatic: + return newStaticTokenAuth(auth.Token), nil + case config.AuthModeOIDC: + return newOIDCAuth(auth.OIDC) + default: + // config.buildAuth rejects unknown modes, so this is unreachable via + // the config path. Fail closed rather than silently serving an open + // API if a caller ever constructs an unexpected mode directly. + return nil, fmt.Errorf("unknown auth mode %q", auth.Mode) } - - return noneAuth{} } func (s *Server) Run(ctx context.Context) error {