From e5a716ef76422e06ac2edf62cdc2182ad496ae54 Mon Sep 17 00:00:00 2001 From: David Whittington Date: Sat, 15 Aug 2026 15:19:09 -0500 Subject: [PATCH 1/4] feat(auth): verify OIDC access tokens for the API Authenticate bounded JWT bearer tokens with strict asymmetric JOSE verification, exact issuer and audience checks, integer time claims, supervised JWKS rotation, and exact claim-value capability mapping. Unknown key refreshes are globally rate-limited and failures remain generic and fail closed. A temporary coarse gate requires all current API capabilities until T-233 adds route-specific authorization. --- config/runtime.exs | 15 + docs/api.md | 21 +- docs/configuration.md | 33 +- lib/smolquery/auth/oidc/config.ex | 83 +++++ lib/smolquery/auth/oidc/discovery.ex | 51 ++- lib/smolquery/auth/oidc/provider.ex | 30 +- lib/smolquery/auth/oidc/token.ex | 341 ++++++++++++++++++ lib/smolquery/runtime_config.ex | 22 ++ lib/smolquery_api/auth.ex | 43 ++- lib/smolquery_api/runtime.ex | 5 +- test/smolquery/auth/oidc/config_test.exs | 38 ++ test/smolquery/auth/oidc/discovery_test.exs | 42 ++- test/smolquery/auth/oidc/provider_test.exs | 93 ++++- test/smolquery/auth/oidc/token_test.exs | 374 ++++++++++++++++++++ test/smolquery/runtime_config_test.exs | 21 +- test/smolquery_api/oidc_auth_test.exs | 117 ++++++ test/smolquery_api/router_test.exs | 14 +- 17 files changed, 1289 insertions(+), 54 deletions(-) create mode 100644 lib/smolquery/auth/oidc/token.ex create mode 100644 test/smolquery/auth/oidc/token_test.exs create mode 100644 test/smolquery_api/oidc_auth_test.exs diff --git a/config/runtime.exs b/config/runtime.exs index fbd157a5..ebe5a009 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -64,9 +64,24 @@ if mappings = System.get_env("SMOLQUERY_OIDC_CLAIM_CAPABILITIES") do Smolquery.RuntimeConfig.capability_mapping!("SMOLQUERY_OIDC_CLAIM_CAPABILITIES", mappings) end +if token_types = System.get_env("SMOLQUERY_OIDC_TOKEN_TYPES") do + config :smolquery, Smolquery.Auth.OIDC.Config, + typ_allowlist: Smolquery.RuntimeConfig.csv!("SMOLQUERY_OIDC_TOKEN_TYPES", token_types) +end + +if required_claims = System.get_env("SMOLQUERY_OIDC_REQUIRED_CLAIMS") do + config :smolquery, Smolquery.Auth.OIDC.Config, + required_claims: + Smolquery.RuntimeConfig.string_lists!("SMOLQUERY_OIDC_REQUIRED_CLAIMS", required_claims) +end + for {env, key, max} <- [ + {"SMOLQUERY_OIDC_MAX_TOKEN_BYTES", :max_token_bytes, 1_048_576}, + {"SMOLQUERY_OIDC_MAX_TOKEN_SEGMENT_BYTES", :max_segment_bytes, 524_288}, + {"SMOLQUERY_OIDC_IAT_FUTURE_SECONDS", :iat_future_seconds, 86_400}, {"SMOLQUERY_OIDC_DISCOVERY_MAX_AGE_MS", :discovery_max_age_ms, 86_400_000}, {"SMOLQUERY_OIDC_JWKS_MAX_AGE_MS", :jwks_max_age_ms, 86_400_000}, + {"SMOLQUERY_OIDC_FORCED_REFRESH_COOLDOWN_MS", :forced_refresh_cooldown_ms, 86_400_000}, {"SMOLQUERY_OIDC_CONNECT_TIMEOUT_MS", :connect_timeout_ms, 30_000}, {"SMOLQUERY_OIDC_RECEIVE_TIMEOUT_MS", :receive_timeout_ms, 60_000}, {"SMOLQUERY_OIDC_REQUEST_TIMEOUT_MS", :request_timeout_ms, 120_000}, diff --git a/docs/api.md b/docs/api.md index 317e0659..0f033831 100644 --- a/docs/api.md +++ b/docs/api.md @@ -4,17 +4,26 @@ same stack as the web UI's `SmolqueryWeb`), started by the `:api` role, routing only to service client modules and the catalog (the same boundary rule the services hold each other to). Set `SMOLQUERY_AUTH_MODE=static` for the static -Bearer-key adapter, or `oidc` for the validated OIDC foundation. T-231 keeps -OIDC API requests denied until T-232 adds token verification. Every static -`/v1` request requires `SMOLQUERY_API_KEY`; a node with no mode or required -credentials fails the boot rather than serve an open API. Successful static -requests carry a normalized service principal and context. `/healthz` is the -one unauthenticated route. +Bearer-key adapter, or `oidc` for OIDC access-token verification. Every +static `/v1` request requires `SMOLQUERY_API_KEY`; OIDC requests require a +signed bearer access token matching the configured issuer and audience. A node +with no mode or required credentials fails the boot rather than serve an open +API. Successful requests carry a normalized principal and context. `/healthz` +is the one unauthenticated route. + +T-232 performs authentication before body parsing and deliberately applies a +coarse safe gate: an OIDC token must map to `query`, `ingest`, and +`catalog_manage`. T-233 will move these decisions to per-route capability +checks; until then, tokens missing any one of those capabilities receive the +same 401 response as invalid tokens. ```sh curl http://127.0.0.1:4000/healthz auth='authorization: Bearer '$SMOLQUERY_API_KEY +# In OIDC mode, replace the static key with an access token issued for +# SMOLQUERY_OIDC_API_AUDIENCE: +# auth='authorization: Bearer '$OIDC_ACCESS_TOKEN json='content-type: application/json' curl -H "$auth" -H "$json" -d '{"id": "analytics"}' http://127.0.0.1:4000/v1/datasets curl -H "$auth" -H "$json" -d '{"id": "events", "schema": [ diff --git a/docs/configuration.md b/docs/configuration.md index 83d75e28..62431b93 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -30,10 +30,10 @@ error. ### OIDC foundation (T-231) OIDC mode is explicit and fail-closed. The API and web roles validate their -own required settings before their listeners start. T-231 only starts and -validates the discovery/JWKS cache; request authentication and browser login -are added by later stack layers. Provider outage or malformed discovery/JWKS -never opens either listener. +own required settings before their listeners start. T-232 verifies API bearer +access tokens against the supervised discovery/JWKS cache. Browser login and +per-route capability authorization are added by later stack layers. Provider +outage or malformed discovery/JWKS never opens either listener. | variable | effect | |---|---| @@ -46,15 +46,34 @@ never opens either listener. | `SMOLQUERY_OIDC_WEB_REDIRECT_URI` | exact HTTPS authorization callback URI | | `SMOLQUERY_OIDC_ALGORITHMS` | comma-separated local allowlist (default `RS256`); token or discovery metadata never expands it | | `SMOLQUERY_OIDC_CLOCK_SKEW` | bounded non-negative seconds for later token validation (default `30`) | -| `SMOLQUERY_OIDC_CLAIM_CAPABILITIES` | optional JSON object mapping claim names to exact string values and capability arrays, e.g. `{"roles":{"reader":["query"],"operator":["web_access","query","platform_operate"]}}` | +| `SMOLQUERY_OIDC_CLAIM_CAPABILITIES` | optional JSON object mapping claim names to exact string values and capability arrays, e.g. `{"roles":{"reader":["query"],"operator":["web_access","query","platform_operate"]}}`; list-valued token claims union matching values | +| `SMOLQUERY_OIDC_TOKEN_TYPES` | optional comma-separated protected-header `typ` allowlist; when set, a token must carry one exact type | +| `SMOLQUERY_OIDC_REQUIRED_CLAIMS` | optional JSON object mapping required payload claim names to allowed exact string values, e.g. `{"token_use":["access"]}` | +| `SMOLQUERY_OIDC_MAX_TOKEN_BYTES` / `SMOLQUERY_OIDC_MAX_TOKEN_SEGMENT_BYTES` | bounds compact token and individual encoded segments before JOSE decoding (defaults `65536` / `32768`) | +| `SMOLQUERY_OIDC_IAT_FUTURE_SECONDS` | maximum future `iat` allowance (default `300`); `exp` contexts remain active through the configured clock-skew boundary | | `SMOLQUERY_OIDC_DISCOVERY_MAX_AGE_MS` / `SMOLQUERY_OIDC_JWKS_MAX_AGE_MS` | bounded cache freshness windows (defaults `3600000`) | +| `SMOLQUERY_OIDC_FORCED_REFRESH_COOLDOWN_MS` | minimum interval between unknown-`kid` forced JWKS fetch attempts across all requests (default `1000`); concurrent/repeated attempts within the interval reuse the current cache and fail closed if the key is still unknown | | `SMOLQUERY_OIDC_CONNECT_TIMEOUT_MS` / `SMOLQUERY_OIDC_RECEIVE_TIMEOUT_MS` / `SMOLQUERY_OIDC_REQUEST_TIMEOUT_MS` | bounded Req connection, per-chunk receive, and complete-response timeouts (defaults `2000` / `5000` / `10000`) | | `SMOLQUERY_OIDC_MAX_BODY_BYTES` | bounded discovery/JWKS response size (default `1048576`) | +The API verifier requires a non-empty `kid`, a locally allowlisted asymmetric +algorithm, a compatible public signing key, exact issuer and audience, and +integer NumericDate claims. Unknown keys trigger one supervised JWKS refresh, subject to the global forced-refresh cooldown; +concurrent or repeated unknown keys within that cooldown reuse the current cache and reject +without another network fetch. All other failures reject without revealing the verification reason. The +context expiry is `exp + SMOLQUERY_OIDC_CLOCK_SKEW`, matching the accepted +expiration-skew boundary. Before T-233, an OIDC API token must map to all three +current API capabilities (`query`, `ingest`, and `catalog_manage`), so this layer +cannot accidentally grant a query-only token write or catalog access. + The discovery client requires JSON responses, byte-for-byte issuer equality, HTTPS authorization/token/JWKS endpoints, and an algorithm overlap with the local -asymmetric allowlist. It refuses redirects and bounds response bodies. It does not trust `jku`, token headers, claims, or provider groups -as tenant identifiers. +asymmetric allowlist. It refuses redirects and bounds response bodies. Unknown `kid` +refreshes use a global cooldown, so concurrent or repeated misses cannot create one +outbound fetch per request; failed attempts also start the cooldown and subsequent +misses fail against the current cache. Protected `jku`, `jwk`, `x5u`, `crit`, and +`b64` headers are rejected. It does not trust token claims or provider groups as +tenant identifiers. | `SMOLQUERY_INTERNAL_SECRET` | what internal HTTP proves itself with; generated per boot on a single node, required as a non-empty shared value before a cluster boots | diff --git a/lib/smolquery/auth/oidc/config.ex b/lib/smolquery/auth/oidc/config.ex index 4d4eb999..d673e3bf 100644 --- a/lib/smolquery/auth/oidc/config.ex +++ b/lib/smolquery/auth/oidc/config.ex @@ -23,8 +23,14 @@ defmodule Smolquery.Auth.OIDC.Config do :algorithms, :clock_skew, :claim_capabilities, + :typ_allowlist, + :required_claims, + :max_token_bytes, + :max_segment_bytes, + :iat_future_seconds, :discovery_max_age_ms, :jwks_max_age_ms, + :forced_refresh_cooldown_ms, :connect_timeout_ms, :receive_timeout_ms, :request_timeout_ms, @@ -34,6 +40,7 @@ defmodule Smolquery.Auth.OIDC.Config do @type claim_capabilities :: %{ optional(String.t()) => %{optional(String.t()) => [Context.capability()]} } + @type required_claims :: %{optional(String.t()) => [String.t()]} @type t :: %__MODULE__{ issuer: String.t(), api_audience: String.t() | nil, @@ -45,8 +52,14 @@ defmodule Smolquery.Auth.OIDC.Config do algorithms: [String.t()], clock_skew: non_neg_integer(), claim_capabilities: claim_capabilities(), + typ_allowlist: [String.t()], + required_claims: required_claims(), + max_token_bytes: pos_integer(), + max_segment_bytes: pos_integer(), + iat_future_seconds: non_neg_integer(), discovery_max_age_ms: non_neg_integer(), jwks_max_age_ms: non_neg_integer(), + forced_refresh_cooldown_ms: non_neg_integer(), connect_timeout_ms: pos_integer(), receive_timeout_ms: pos_integer(), request_timeout_ms: pos_integer(), @@ -58,10 +71,16 @@ defmodule Smolquery.Auth.OIDC.Config do clock_skew: 30, discovery_max_age_ms: 3_600_000, jwks_max_age_ms: 3_600_000, + forced_refresh_cooldown_ms: 1_000, connect_timeout_ms: 2_000, receive_timeout_ms: 5_000, request_timeout_ms: 10_000, max_body_bytes: 1_048_576, + typ_allowlist: [], + required_claims: %{}, + max_token_bytes: 65_536, + max_segment_bytes: 32_768, + iat_future_seconds: 300, web_client_auth_method: :client_secret_basic, claim_capabilities: %{} ] @@ -88,6 +107,11 @@ defmodule Smolquery.Auth.OIDC.Config do algorithms = algorithms!(Keyword.fetch!(config, :algorithms)) claim_capabilities = claim_capabilities!(Keyword.fetch!(config, :claim_capabilities)) + typ_allowlist = + string_allowlist!(Keyword.fetch!(config, :typ_allowlist), "SMOLQUERY_OIDC_TOKEN_TYPES") + + required_claims = required_claims!(Keyword.fetch!(config, :required_claims)) + %__MODULE__{ issuer: issuer, api_audience: api_audience, @@ -99,6 +123,29 @@ defmodule Smolquery.Auth.OIDC.Config do algorithms: algorithms, clock_skew: bounded_integer!(config, :clock_skew, "SMOLQUERY_OIDC_CLOCK_SKEW", 300), claim_capabilities: claim_capabilities, + typ_allowlist: typ_allowlist, + required_claims: required_claims, + max_token_bytes: + positive_bounded_integer!( + config, + :max_token_bytes, + "SMOLQUERY_OIDC_MAX_TOKEN_BYTES", + 1_048_576 + ), + max_segment_bytes: + positive_bounded_integer!( + config, + :max_segment_bytes, + "SMOLQUERY_OIDC_MAX_TOKEN_SEGMENT_BYTES", + 524_288 + ), + iat_future_seconds: + bounded_integer!( + config, + :iat_future_seconds, + "SMOLQUERY_OIDC_IAT_FUTURE_SECONDS", + 86_400 + ), discovery_max_age_ms: bounded_integer!( config, @@ -108,6 +155,13 @@ defmodule Smolquery.Auth.OIDC.Config do ), jwks_max_age_ms: bounded_integer!(config, :jwks_max_age_ms, "SMOLQUERY_OIDC_JWKS_MAX_AGE_MS", 86_400_000), + forced_refresh_cooldown_ms: + bounded_integer!( + config, + :forced_refresh_cooldown_ms, + "SMOLQUERY_OIDC_FORCED_REFRESH_COOLDOWN_MS", + 86_400_000 + ), connect_timeout_ms: positive_bounded_integer!( config, @@ -172,6 +226,35 @@ defmodule Smolquery.Auth.OIDC.Config do def algorithms, do: @algorithms + @doc "Returns the closed set of configured protected-header token types." + @spec string_allowlist!(term(), String.t()) :: [String.t()] + def string_allowlist!([], _env), do: [] + + def string_allowlist!(values, env) when is_list(values) do + if Enum.all?(values, &nonempty_string?/1) and length(values) == length(Enum.uniq(values)), + do: values, + else: invalid!(env, values, "a unique non-empty string list") + end + + def string_allowlist!(value, env), do: invalid!(env, value, "a string list") + + @doc "Validates exact required payload claim values." + @spec required_claims!(term()) :: required_claims() + def required_claims!(claims) when is_map(claims) do + Enum.reduce(claims, %{}, fn {claim, values}, acc -> + if nonempty_string?(claim) and is_list(values) and values != [] and + Enum.all?(values, &nonempty_string?/1) and + length(values) == length(Enum.uniq(values)) do + Map.put(acc, claim, values) + else + invalid!("SMOLQUERY_OIDC_REQUIRED_CLAIMS", claims, "a map of claim names to string lists") + end + end) + end + + def required_claims!(value), + do: invalid!("SMOLQUERY_OIDC_REQUIRED_CLAIMS", value, "a map of claim names to string lists") + @spec raise_invalid_claim_mapping(term()) :: no_return() defp raise_invalid_claim_mapping(mapping), do: diff --git a/lib/smolquery/auth/oidc/discovery.ex b/lib/smolquery/auth/oidc/discovery.ex index 410c6a1c..e28792f0 100644 --- a/lib/smolquery/auth/oidc/discovery.ex +++ b/lib/smolquery/auth/oidc/discovery.ex @@ -252,13 +252,13 @@ defmodule Smolquery.Auth.OIDC.Discovery do defp public_jwk_shape?(%{"kid" => kid, "kty" => "RSA", "n" => n, "e" => e} = key) when is_binary(kid) and kid != "" and is_binary(n) and is_binary(e) do - no_private_fields?(key) and valid_b64url?(n) and valid_b64url?(e) + no_private_fields?(key) and strong_rsa_public_key?(n, e) end defp public_jwk_shape?(%{"kid" => kid, "kty" => "EC", "crv" => curve, "x" => x, "y" => y} = key) when is_binary(kid) and kid != "" and curve in ["P-256", "P-384", "P-521"] and is_binary(x) and is_binary(y) do - no_private_fields?(key) and valid_b64url?(x) and valid_b64url?(y) + no_private_fields?(key) and valid_ec_coordinate?(curve, x) and valid_ec_coordinate?(curve, y) end defp public_jwk_shape?(%{"kid" => kid, "kty" => "OKP", "crv" => curve, "x" => x} = key) @@ -271,9 +271,48 @@ defmodule Smolquery.Auth.OIDC.Discovery do defp no_private_fields?(key), do: Enum.all?(~w(d p q dp dq qi oth k), &(not Map.has_key?(key, &1))) - defp valid_b64url?(value) do - value != "" and match?({:ok, _}, Base.url_decode64(value, padding: false)) and - Base.url_encode64(elem(Base.url_decode64(value, padding: false), 1), padding: false) == - value + defp strong_rsa_public_key?(modulus, exponent) do + with {:ok, modulus_bytes} <- canonical_b64url(modulus), + {:ok, exponent_bytes} <- canonical_b64url(exponent), + true <- rsa_modulus_at_least_2048_bits?(modulus_bytes), + exponent <- :binary.decode_unsigned(exponent_bytes), + true <- exponent >= 3 and rem(exponent, 2) == 1 do + true + else + _invalid -> false + end end + + defp rsa_modulus_at_least_2048_bits?(<<0, _rest::binary>>), do: false + + defp rsa_modulus_at_least_2048_bits?(<> = modulus) do + byte_size(modulus) > 256 or (byte_size(modulus) == 256 and first >= 128) + end + + defp rsa_modulus_at_least_2048_bits?(_modulus), do: false + + defp valid_ec_coordinate?(curve, coordinate) do + expected_bytes = %{"P-256" => 32, "P-384" => 48, "P-521" => 66} + + case canonical_b64url(coordinate) do + {:ok, decoded} -> byte_size(decoded) == Map.fetch!(expected_bytes, curve) + :error -> false + end + end + + defp valid_b64url?(value), do: match?({:ok, _decoded}, canonical_b64url(value)) + + defp canonical_b64url(value) when is_binary(value) and value != "" do + case Base.url_decode64(value, padding: false) do + {:ok, decoded} -> + if decoded != "" and Base.url_encode64(decoded, padding: false) == value, + do: {:ok, decoded}, + else: :error + + :error -> + :error + end + end + + defp canonical_b64url(_value), do: :error end diff --git a/lib/smolquery/auth/oidc/provider.ex b/lib/smolquery/auth/oidc/provider.ex index 3d6112c8..636fc6ac 100644 --- a/lib/smolquery/auth/oidc/provider.ex +++ b/lib/smolquery/auth/oidc/provider.ex @@ -21,6 +21,7 @@ defmodule Smolquery.Auth.OIDC.Provider do metadata_at: integer(), jwks: map(), jwks_at: integer(), + forced_refresh_at: integer() | nil, http_client: Discovery.http_client() } @@ -39,7 +40,7 @@ defmodule Smolquery.Auth.OIDC.Provider do @spec jwks(pid() | atom()) :: {:ok, map()} | {:error, term()} def jwks(server), do: GenServer.call(server, :jwks, @operation_timeout_ms) - @doc "Forces one bounded JWKS refresh, used later for an unknown key id." + @doc "Refreshes JWKS for an unknown key id, subject to the global cooldown." @spec refresh_jwks(pid() | atom()) :: {:ok, map()} | {:error, term()} def refresh_jwks(server), do: GenServer.call(server, :refresh_jwks, @operation_timeout_ms) @@ -59,6 +60,7 @@ defmodule Smolquery.Auth.OIDC.Provider do metadata_at: now, jwks: jwks, jwks_at: now, + forced_refresh_at: nil, http_client: client }} else @@ -121,7 +123,26 @@ defmodule Smolquery.Auth.OIDC.Provider do end end - defp fetch_jwks_if_needed(:refresh_jwks, state), do: fetch_jwks(state) + defp fetch_jwks_if_needed(:refresh_jwks, state) do + if forced_refresh_allowed?(state) do + fetch_forced_jwks(state) + else + {:ok, {:ok, state.jwks}, state} + end + end + + defp fetch_forced_jwks(state) do + attempted_at = now_ms() + + case Discovery.fetch_jwks(state.config, state.metadata, state.http_client) do + {:ok, jwks} -> + {:ok, {:ok, jwks}, + %{state | jwks: jwks, jwks_at: attempted_at, forced_refresh_at: attempted_at}} + + {:error, reason} -> + {:error, {:jwks_unavailable, reason}, %{state | forced_refresh_at: attempted_at}} + end + end defp fetch_jwks(state) do case Discovery.fetch_jwks(state.config, state.metadata, state.http_client) do @@ -130,6 +151,11 @@ defmodule Smolquery.Auth.OIDC.Provider do end end + defp forced_refresh_allowed?(%{forced_refresh_at: nil}), do: true + + defp forced_refresh_allowed?(state), + do: now_ms() - state.forced_refresh_at >= state.config.forced_refresh_cooldown_ms + defp fresh?(_fetched_at, 0), do: false defp fresh?(fetched_at, max_age), do: now_ms() - fetched_at <= max_age defp now_ms, do: System.monotonic_time(:millisecond) diff --git a/lib/smolquery/auth/oidc/token.ex b/lib/smolquery/auth/oidc/token.ex new file mode 100644 index 00000000..b6ed6d20 --- /dev/null +++ b/lib/smolquery/auth/oidc/token.ex @@ -0,0 +1,341 @@ +defmodule Smolquery.Auth.OIDC.Token do + @moduledoc """ + Verifies OIDC access tokens for the API resource server. + + Header metadata is bounded and used only to select a key from the supervised + provider cache. Signature verification is strict and all claims are checked + before a normalized context is built. Verification failures are deliberately + collapsed to `:error` at the adapter boundary. + """ + + alias Smolquery.Auth.Context + alias Smolquery.Auth.OIDC.{Config, Discovery, Provider} + alias Smolquery.Auth.Principal + + @max_subject_bytes 4_096 + @display_claims ["name", "preferred_username"] + @client_claims ["client_id", "azp"] + + @type result :: {:ok, Context.t()} | :error + + @doc "Verifies an access token through a supervised provider cache." + @spec authenticate(String.t(), Config.t(), pid() | atom(), keyword()) :: result() + def authenticate(token, config, provider, opts \\ []) do + now = Keyword.get(opts, :now, System.system_time(:second)) + + with {:ok, header} <- parse_header(token, config), + :ok <- validate_header(header, config), + {:ok, jwks} <- provider_jwks(provider), + {:ok, jwk} <- select_or_refresh(header, jwks, provider, config), + {:ok, claims} <- verify_claims(token, jwk, config, now), + {:ok, context} <- normalize(claims, config) do + {:ok, context} + else + _failure -> :error + end + end + + @doc "Verifies a token against supplied JWKS, primarily for deterministic tests." + @spec verify(String.t(), Config.t(), map(), map(), keyword()) :: result() + def verify(token, config, jwks, refreshed_jwks, opts \\ []) do + now = Keyword.get(opts, :now, System.system_time(:second)) + + with {:ok, header} <- parse_header(token, config), + :ok <- validate_header(header, config), + {:ok, jwk} <- select_or_refresh_with(header, jwks, refreshed_jwks, config), + {:ok, claims} <- verify_claims(token, jwk, config, now), + {:ok, context} <- normalize(claims, config) do + {:ok, context} + else + _failure -> :error + end + end + + defp parse_header(token, %Config{} = config) when is_binary(token) do + if byte_size(token) <= config.max_token_bytes do + parse_segments(String.split(token, ".", trim: false), config.max_segment_bytes) + else + :error + end + end + + defp parse_header(_token, _config), do: :error + + defp parse_segments([header, payload, signature], max_segment_bytes) + when byte_size(header) <= max_segment_bytes and + byte_size(payload) <= max_segment_bytes and + byte_size(signature) <= max_segment_bytes do + case decode_segment(header) do + {:ok, fields} when is_map(fields) -> {:ok, fields} + _failure -> :error + end + end + + defp parse_segments(_segments, _max_segment_bytes), do: :error + + defp decode_segment(segment) do + case Base.url_decode64(segment, padding: false) do + {:ok, decoded} -> JSON.decode(decoded) + :error -> :error + end + end + + defp validate_header(header, config) do + with alg when is_binary(alg) <- Map.get(header, "alg"), + true <- alg in config.algorithms, + kid when is_binary(kid) <- Map.get(header, "kid"), + true <- kid != "" and byte_size(kid) <= @max_subject_bytes, + false <- Enum.any?(["jku", "jwk", "x5u", "crit", "b64"], &Map.has_key?(header, &1)), + :ok <- validate_type(header, config) do + :ok + else + _failure -> :error + end + end + + defp validate_type(header, %Config{typ_allowlist: []}) do + case Map.fetch(header, "typ") do + :error -> :ok + {:ok, typ} when is_binary(typ) and typ != "" -> :ok + _present -> :error + end + end + + defp validate_type(header, %Config{typ_allowlist: allowlist}), + do: if(Map.get(header, "typ") in allowlist, do: :ok, else: :error) + + defp select_or_refresh(header, jwks, provider, config) do + case select_key(header, jwks, config) do + {:error, :unknown_kid} -> + case provider_refresh_jwks(provider) do + {:ok, refreshed} -> select_key(header, refreshed, config) + _failure -> :error + end + + result -> + result + end + end + + defp provider_jwks(provider), do: provider_call(fn -> Provider.jwks(provider) end) + + defp provider_refresh_jwks(provider), + do: provider_call(fn -> Provider.refresh_jwks(provider) end) + + defp provider_call(fun) do + fun.() + catch + :exit, _reason -> :error + end + + defp select_or_refresh_with(header, jwks, refreshed_jwks, config) do + case select_key(header, jwks, config) do + {:error, :unknown_kid} -> select_key(header, refreshed_jwks, config) + result -> result + end + end + + defp select_key(%{"kid" => kid, "alg" => alg}, %{"keys" => keys}, config) + when is_list(keys) do + matching = Enum.filter(keys, &(is_map(&1) and Map.get(&1, "kid") == kid)) + + case matching do + [] -> {:error, :unknown_kid} + [_key, _another | _rest] -> :error + [key] -> compatible_key(key, alg, config) + end + end + + defp select_key(_header, _jwks, _config), do: :error + + defp compatible_key(key, alg, config) when is_map(key) do + if valid_key_metadata?(key, alg, config) do + jwk_from_map(key) + else + :error + end + end + + defp compatible_key(_key, _alg, _config), do: :error + + defp valid_key_metadata?(key, alg, config) do + key_use = Map.get(key, "use") + key_alg = Map.get(key, "alg") + key_ops = Map.get(key, "key_ops") + + (is_nil(key_use) or key_use == "sig") and + (is_nil(key_alg) or key_alg == alg) and + (is_nil(key_ops) or (is_list(key_ops) and "verify" in key_ops)) and + public_key_compatible?(key, alg) and alg in config.algorithms + end + + defp public_key_compatible?(%{"kty" => "RSA"} = key, alg) do + (String.starts_with?(alg, "RS") or String.starts_with?(alg, "PS")) and + Discovery.validate_jwks(%{"keys" => [key]}) == :ok + end + + defp public_key_compatible?(%{"kty" => "EC", "crv" => curve} = key, alg) do + expected_curve = %{"ES256" => "P-256", "ES384" => "P-384", "ES512" => "P-521"} + Map.get(expected_curve, alg) == curve and Discovery.validate_jwks(%{"keys" => [key]}) == :ok + end + + defp public_key_compatible?(_key, _alg), do: false + + defp jwk_from_map(key) do + {:ok, JOSE.JWK.from_map(key)} + rescue + ArgumentError -> :error + FunctionClauseError -> :error + BadMapError -> :error + ErlangError -> :error + end + + defp verify_claims(token, jwk, config, now) do + case JOSE.JWT.verify_strict(jwk, config.algorithms, token) do + {true, %JOSE.JWT{fields: claims}, _jws} when is_map(claims) -> + validate_claims(claims, config, now) + + _failure -> + :error + end + end + + defp validate_claims(claims, config, now) do + with :ok <- required_claims(claims, config.required_claims), + :ok <- exact_issuer(claims, config.issuer), + :ok <- valid_audience(claims, config.api_audience), + {:ok, subject} <- required_string(claims, "sub"), + {:ok, expires_at} <- valid_expiry(claims, config.clock_skew, now), + :ok <- valid_not_before(claims, config.clock_skew, now), + :ok <- valid_issued_at(claims, config.iat_future_seconds, now) do + {:ok, Map.put(claims, "__expires_at", expires_at) |> Map.put("__subject", subject)} + else + _failure -> :error + end + end + + defp required_claims(claims, required) do + if Enum.all?(required, fn {claim, values} -> + claim_value_matches?(Map.get(claims, claim), values) + end) do + :ok + else + :error + end + end + + defp claim_value_matches?(value, allowed) when is_binary(value), do: value in allowed + + defp claim_value_matches?(values, allowed) when is_list(values) do + {all_strings, matched} = + Enum.reduce(values, {values != [], false}, fn value, {all_strings, matched} -> + {all_strings and is_binary(value) and value != "", matched or value in allowed} + end) + + all_strings and matched + end + + defp claim_value_matches?(_value, _allowed), do: false + + defp exact_issuer(%{"iss" => issuer}, expected) when issuer == expected, do: :ok + defp exact_issuer(_claims, _expected), do: :error + + defp valid_audience(%{"aud" => audience}, expected) when is_binary(audience), + do: if(audience == expected, do: :ok, else: :error) + + defp valid_audience(%{"aud" => audience}, expected) when is_list(audience) do + {all_strings, matched} = + Enum.reduce(audience, {audience != [], false}, fn value, {all_strings, matched} -> + {all_strings and is_binary(value) and value != "", matched or value == expected} + end) + + if all_strings and matched, do: :ok, else: :error + end + + defp valid_audience(_claims, _expected), do: :error + + defp required_string(claims, key) do + case Map.get(claims, key) do + value when is_binary(value) and value != "" and byte_size(value) <= @max_subject_bytes -> + {:ok, value} + + _value -> + :error + end + end + + defp valid_expiry(%{"exp" => exp}, skew, now) when is_integer(exp) and exp >= 0 do + if now < exp + skew, do: {:ok, exp + skew}, else: :error + end + + defp valid_expiry(_claims, _skew, _now), do: :error + + defp valid_not_before(%{"nbf" => nbf}, skew, now) when is_integer(nbf) and nbf >= 0 do + if nbf <= now + skew, do: :ok, else: :error + end + + defp valid_not_before(%{"nbf" => _nbf}, _skew, _now), do: :error + defp valid_not_before(_claims, _skew, _now), do: :ok + + defp valid_issued_at(%{"iat" => iat}, future, now) when is_integer(iat) and iat >= 0 do + if iat <= now + future, do: :ok, else: :error + end + + defp valid_issued_at(%{"iat" => _iat}, _future, _now), do: :error + defp valid_issued_at(_claims, _future, _now), do: :ok + + defp normalize(claims, config) do + capabilities = mapped_capabilities(claims, config.claim_capabilities) + subject = Map.fetch!(claims, "__subject") + expires_at = Map.fetch!(claims, "__expires_at") + + with {:ok, principal} <- + Principal.oidc(config.issuer, subject, :user, principal_options(claims)), + {:ok, context} <- + Context.single_tenant(principal, MapSet.to_list(capabilities), expires_at: expires_at) do + {:ok, context} + else + _failure -> :error + end + end + + defp mapped_capabilities(claims, mappings) do + Enum.reduce(mappings, MapSet.new(), fn {claim, values}, capabilities -> + map_claim_values(Map.get(claims, claim), values, capabilities) + end) + end + + defp map_claim_values(value, values, capabilities) when is_binary(value), + do: union_capabilities(capabilities, Map.get(values, value, [])) + + defp map_claim_values(values, mapping, capabilities) when is_list(values) do + if values != [] and Enum.all?(values, &(is_binary(&1) and &1 != "")) do + Enum.reduce(values, capabilities, fn value, acc -> + union_capabilities(acc, Map.get(mapping, value, [])) + end) + else + capabilities + end + end + + defp map_claim_values(_value, _mapping, capabilities), do: capabilities + + defp union_capabilities(capabilities, values), + do: Enum.reduce(values, capabilities, &MapSet.put(&2, &1)) + + defp principal_options(claims) do + display_name = Enum.find_value(@display_claims, &string_claim(claims, &1)) + client_id = Enum.find_value(@client_claims, &string_claim(claims, &1)) + [display_name: display_name, client_id: client_id] + end + + defp string_claim(claims, key) do + case Map.get(claims, key) do + value when is_binary(value) and value != "" and byte_size(value) <= @max_subject_bytes -> + value + + _value -> + nil + end + end +end diff --git a/lib/smolquery/runtime_config.ex b/lib/smolquery/runtime_config.ex index 8765537b..a388b436 100644 --- a/lib/smolquery/runtime_config.ex +++ b/lib/smolquery/runtime_config.ex @@ -70,6 +70,28 @@ defmodule Smolquery.RuntimeConfig do else: invalid!(name, value, "a non-empty comma-separated list") end + @doc "Parses a JSON object of non-empty string lists." + @spec string_lists!(String.t(), String.t()) :: %{String.t() => [String.t()]} + def string_lists!(name, value) do + case JSON.decode(value) do + {:ok, map} when is_map(map) -> + Enum.reduce(map, %{}, &parse_string_list(name, value, &1, &2)) + + _ -> + invalid!(name, value, "a JSON object of non-empty string lists") + end + end + + defp parse_string_list(name, original, {claim, values}, acc) do + if is_binary(claim) and claim != "" and is_list(values) and values != [] and + Enum.all?(values, &(is_binary(&1) and &1 != "")) and + length(values) == length(Enum.uniq(values)) do + Map.put(acc, claim, values) + else + invalid!(name, original, "a JSON object of non-empty string lists") + end + end + @doc "Parses a bounded non-negative integer." @spec bounded_non_negative_integer!(String.t(), String.t(), pos_integer()) :: non_neg_integer() def bounded_non_negative_integer!(name, value, maximum) do diff --git a/lib/smolquery_api/auth.ex b/lib/smolquery_api/auth.ex index 1ec8f8ec..d087c168 100644 --- a/lib/smolquery_api/auth.ex +++ b/lib/smolquery_api/auth.ex @@ -1,8 +1,10 @@ defmodule SmolqueryApi.Auth do @moduledoc """ - Bearer-key authentication for every `/v1` route (PL-8 D5). + Bearer authentication for every `/v1` route (PL-8 D5). - One static key, compared in constant time. `/healthz` is exempt — a load + Static credentials use one key compared in constant time. OIDC credentials + use strict access-token verification against the supervised provider cache. + `GET /healthz` is exempt — a load balancer probing liveness holds no credentials — and `/metrics` answers to the *internal* secret instead of the API key: metrics are for operators, not tenants, and the scraper is the same class of caller as a hot-tier @@ -21,6 +23,8 @@ defmodule SmolqueryApi.Auth do import Plug.Conn alias Smolquery.Auth + alias Smolquery.Auth.Context + alias Smolquery.Auth.OIDC.Token alias Smolquery.InternalSecret alias SmolqueryApi.Errors alias SmolqueryApi.Runtime @@ -29,7 +33,7 @@ defmodule SmolqueryApi.Auth do def init(opts), do: opts @impl Plug - def call(%Plug.Conn{path_info: ["healthz"]} = conn, _opts), do: conn + def call(%Plug.Conn{method: "GET", path_info: ["healthz"]} = conn, _opts), do: conn def call(%Plug.Conn{path_info: ["metrics"]} = conn, _opts) do if internal?(conn) do @@ -48,7 +52,7 @@ defmodule SmolqueryApi.Auth do :error -> conn - |> Errors.send_error(401, "UNAUTHENTICATED", "missing or invalid API key") + |> Errors.send_error(401, "UNAUTHENTICATED", "missing or invalid API credential") |> halt() end end @@ -61,13 +65,34 @@ defmodule SmolqueryApi.Auth do end defp authenticated_context(conn) do - with ["Bearer " <> key] <- get_req_header(conn, "authorization"), - {:ok, runtime} <- Runtime.fetch(conn.private.smolquery_api), - :static <- runtime.auth_mode, - true <- Plug.Crypto.secure_compare(key, runtime.api_key) do - {:ok, runtime.context} + with ["Bearer " <> token] <- get_req_header(conn, "authorization"), + {:ok, runtime} <- Runtime.fetch(conn.private.smolquery_api) do + authenticate(runtime, token) else _unauthenticated -> :error end end + + defp authenticate(%{auth_mode: :static, api_key: key, context: context}, token) + when is_binary(key) do + if Plug.Crypto.secure_compare(token, key), do: {:ok, context}, else: :error + end + + defp authenticate(%{auth_mode: :oidc, oidc: config, name: name}, token) do + provider = Module.concat(name, "OIDCProvider") + + case Token.authenticate(token, config, provider) do + {:ok, context} -> + if coarse_api_access?(context), do: {:ok, context}, else: :error + + :error -> + :error + end + end + + defp authenticate(_runtime, _token), do: :error + + defp coarse_api_access?(context) do + Enum.all?([:query, :ingest, :catalog_manage], &Context.granted?(context, &1)) + end end diff --git a/lib/smolquery_api/runtime.ex b/lib/smolquery_api/runtime.ex index 2f0713d1..5b97e68c 100644 --- a/lib/smolquery_api/runtime.ex +++ b/lib/smolquery_api/runtime.ex @@ -70,8 +70,9 @@ defmodule SmolqueryApi.Runtime do Application config for `SmolqueryApi` supplies the defaults; `opts` overrides them. Raises if the authentication mode is missing, or if static - mode has no non-empty `api_key`. OIDC mode validates its provider foundation; - request token verification remains denied until T-232. + mode has no non-empty `api_key`. OIDC mode validates its provider foundation + and verifies API bearer tokens through the supervised provider cache; per-route + capability checks are added by T-233. """ @spec new(keyword()) :: t() def new(opts \\ []) do diff --git a/test/smolquery/auth/oidc/config_test.exs b/test/smolquery/auth/oidc/config_test.exs index a67aef62..d5360920 100644 --- a/test/smolquery/auth/oidc/config_test.exs +++ b/test/smolquery/auth/oidc/config_test.exs @@ -113,6 +113,44 @@ defmodule Smolquery.Auth.OIDC.ConfigTest do end end + test "validates token type and required payload claim settings" do + config = + Config.new( + [ + oidc: + Keyword.merge(@base, + typ_allowlist: ["at+jwt"], + required_claims: %{"token_use" => ["access"]}, + max_token_bytes: 1024, + max_segment_bytes: 512, + iat_future_seconds: 60, + forced_refresh_cooldown_ms: 250 + ) + ], + :api + ) + + assert config.typ_allowlist == ["at+jwt"] + assert config.required_claims == %{"token_use" => ["access"]} + assert config.max_token_bytes == 1024 + assert config.max_segment_bytes == 512 + assert config.iat_future_seconds == 60 + assert config.forced_refresh_cooldown_ms == 250 + + for {key, value, env} <- [ + {:typ_allowlist, ["at+jwt", "at+jwt"], "SMOLQUERY_OIDC_TOKEN_TYPES"}, + {:required_claims, %{"token_use" => []}, "SMOLQUERY_OIDC_REQUIRED_CLAIMS"}, + {:max_token_bytes, 0, "SMOLQUERY_OIDC_MAX_TOKEN_BYTES"}, + {:max_segment_bytes, 0, "SMOLQUERY_OIDC_MAX_TOKEN_SEGMENT_BYTES"}, + {:iat_future_seconds, -1, "SMOLQUERY_OIDC_IAT_FUTURE_SECONDS"}, + {:forced_refresh_cooldown_ms, -1, "SMOLQUERY_OIDC_FORCED_REFRESH_COOLDOWN_MS"} + ] do + assert_raise ArgumentError, ~r/#{env}/, fn -> + Config.new([oidc: Keyword.put(@base, key, value)], :api) + end + end + end + test "redacts the client secret" do config = Config.new([oidc: @base], :web) refute inspect(config) =~ "\"secret\"" diff --git a/test/smolquery/auth/oidc/discovery_test.exs b/test/smolquery/auth/oidc/discovery_test.exs index 1e1a307f..70162516 100644 --- a/test/smolquery/auth/oidc/discovery_test.exs +++ b/test/smolquery/auth/oidc/discovery_test.exs @@ -4,6 +4,16 @@ defmodule Smolquery.Auth.OIDC.DiscoveryTest do alias Smolquery.Auth.OIDC.{Config, Discovery} @config Config.new([oidc: [issuer: "https://issuer.example/", api_audience: "api"]], :api) + @rsa_public JOSE.JWK.generate_key({:rsa, 2048}) + |> JOSE.JWK.to_public() + |> JOSE.JWK.to_map() + |> elem(1) + |> Map.put("kid", "one") + @ec_public JOSE.JWK.generate_key({:ec, "P-256"}) + |> JOSE.JWK.to_public() + |> JOSE.JWK.to_map() + |> elem(1) + |> Map.put("kid", "ec-one") @metadata %{ "issuer" => "https://issuer.example/", "authorization_endpoint" => "https://login.example/authorize", @@ -38,7 +48,7 @@ defmodule Smolquery.Auth.OIDC.DiscoveryTest do {:ok, response(@metadata, 200, "Application/JSON; charset=utf-8")} end) - jwks = %{"keys" => [%{"kid" => "one", "kty" => "RSA", "n" => "AQ", "e" => "AQAB"}]} + jwks = %{"keys" => [@rsa_public]} assert {:ok, _} = Discovery.fetch_jwks(@config, @metadata, fn _, _ -> @@ -93,15 +103,25 @@ defmodule Smolquery.Auth.OIDC.DiscoveryTest do end test "rejects malformed and private JWKS keys" do - assert :ok == + assert :ok == Discovery.validate_jwks(%{"keys" => [@rsa_public]}) + assert :ok == Discovery.validate_jwks(%{"keys" => [@ec_public]}) + + assert {:error, :jwks_malformed} = Discovery.validate_jwks(%{ - "keys" => [%{"kid" => "one", "kty" => "RSA", "n" => "AQ", "e" => "AQAB"}] + "keys" => [%{"kid" => "weak", "kty" => "RSA", "n" => "AQ", "e" => "AQAB"}] }) - assert :ok == + padded_weak_modulus = Base.url_encode64(<<0, 1::size(2048)>>, padding: false) + + assert {:error, :jwks_malformed} = Discovery.validate_jwks(%{ "keys" => [ - %{"kid" => "one", "kty" => "EC", "crv" => "P-256", "x" => "AQ", "y" => "AQ"} + %{ + "kid" => "padded-weak", + "kty" => "RSA", + "n" => padded_weak_modulus, + "e" => "AQAB" + } ] }) @@ -111,18 +131,10 @@ defmodule Smolquery.Auth.OIDC.DiscoveryTest do }) assert {:error, :jwks_malformed} = - Discovery.validate_jwks(%{ - "keys" => [ - %{"kid" => "one", "kty" => "RSA", "n" => "AQ", "e" => "AQAB", "d" => "private"} - ] - }) + Discovery.validate_jwks(%{"keys" => [Map.put(@rsa_public, "d", "private")]}) assert {:error, :jwks_malformed} = - Discovery.validate_jwks(%{ - "keys" => [ - %{"kid" => "one", "kty" => "RSA", "n" => "AQ", "e" => "AQAB", "oth" => []} - ] - }) + Discovery.validate_jwks(%{"keys" => [Map.put(@rsa_public, "oth", [])]}) end defp response(body, status \\ 200, content_type \\ "application/json") do diff --git a/test/smolquery/auth/oidc/provider_test.exs b/test/smolquery/auth/oidc/provider_test.exs index a5a14770..556fcc55 100644 --- a/test/smolquery/auth/oidc/provider_test.exs +++ b/test/smolquery/auth/oidc/provider_test.exs @@ -10,7 +10,18 @@ defmodule Smolquery.Auth.OIDC.ProviderTest do "jwks_uri" => "https://keys.example/keys", "id_token_signing_alg_values_supported" => ["RS256"] } - @jwks %{"keys" => [%{"kid" => "one", "kty" => "RSA", "n" => "AQ", "e" => "AQAB"}]} + @public_key JOSE.JWK.generate_key({:rsa, 2048}) + |> JOSE.JWK.to_public() + |> JOSE.JWK.to_map() + |> elem(1) + |> Map.put("kid", "one") + @rotated_public_key JOSE.JWK.generate_key({:rsa, 2048}) + |> JOSE.JWK.to_public() + |> JOSE.JWK.to_map() + |> elem(1) + |> Map.put("kid", "two") + @jwks %{"keys" => [@public_key]} + @rotated_jwks %{"keys" => [@rotated_public_key]} test "loads discovery and JWKS before becoming available" do test_pid = self() @@ -58,7 +69,7 @@ defmodule Smolquery.Auth.OIDC.ProviderTest do test "metadata refresh atomically replaces keys when the JWKS URI changes" do metadata = Map.put(@metadata, "jwks_uri", "https://keys.example/rotated") - rotated = %{"keys" => [%{"kid" => "two", "kty" => "RSA", "n" => "Ag", "e" => "AQAB"}]} + rotated = @rotated_jwks {:ok, counter} = Agent.start_link(fn -> 0 end) client = fn url, _options -> @@ -91,7 +102,7 @@ defmodule Smolquery.Auth.OIDC.ProviderTest do end test "metadata refresh observes key rotation even when the JWKS URI is unchanged" do - rotated = %{"keys" => [%{"kid" => "two", "kty" => "RSA", "n" => "Ag", "e" => "AQAB"}]} + rotated = @rotated_jwks {:ok, key_counter} = Agent.start_link(fn -> 0 end) client = fn url, _options -> @@ -118,7 +129,81 @@ defmodule Smolquery.Auth.OIDC.ProviderTest do Process.exit(pid, :normal) end - test "forced refresh returns unavailable errors and never accepts unknown keys" do + test "forced refresh accepts rotation once and then reuses the cache during cooldown" do + rotated = @rotated_jwks + {:ok, counter} = Agent.start_link(fn -> 0 end) + + client = fn url, _options -> + case url do + "https://issuer.example/.well-known/openid-configuration" -> + {:ok, response(@metadata)} + + "https://keys.example/keys" -> + count = Agent.get_and_update(counter, fn value -> {value, value + 1} end) + {:ok, response(if(count == 0, do: @jwks, else: rotated))} + end + end + + config = + Config.new( + [ + oidc: [ + issuer: "https://issuer.example/", + api_audience: "api", + forced_refresh_cooldown_ms: 100_000 + ] + ], + :api + ) + + {:ok, pid} = Provider.start_link(name: unique_name(), config: config, http_client: client) + + assert {:ok, ^rotated} = Provider.refresh_jwks(pid) + assert {:ok, ^rotated} = Provider.refresh_jwks(pid) + assert Agent.get(counter, & &1) == 2 + Process.exit(pid, :normal) + end + + test "concurrent forced refreshes perform at most one network fetch per cooldown" do + {:ok, counter} = Agent.start_link(fn -> 0 end) + + client = fn url, _options -> + case url do + "https://issuer.example/.well-known/openid-configuration" -> + {:ok, response(@metadata)} + + "https://keys.example/keys" -> + Agent.update(counter, &(&1 + 1)) + Process.sleep(10) + {:ok, response(@jwks)} + end + end + + config = + Config.new( + [ + oidc: [ + issuer: "https://issuer.example/", + api_audience: "api", + forced_refresh_cooldown_ms: 100_000 + ] + ], + :api + ) + + {:ok, pid} = Provider.start_link(name: unique_name(), config: config, http_client: client) + + results = + 1..8 + |> Enum.map(fn _ -> Task.async(fn -> Provider.refresh_jwks(pid) end) end) + |> Enum.map(&Task.await(&1, 5_000)) + + assert Enum.all?(results, &match?({:ok, @jwks}, &1)) + assert Agent.get(counter, & &1) == 2 + Process.exit(pid, :normal) + end + + test "failed forced refresh starts cooldown and returns stale cache without retrying" do {:ok, counter} = Agent.start_link(fn -> 0 end) client = fn url, _options -> diff --git a/test/smolquery/auth/oidc/token_test.exs b/test/smolquery/auth/oidc/token_test.exs new file mode 100644 index 00000000..f50f139b --- /dev/null +++ b/test/smolquery/auth/oidc/token_test.exs @@ -0,0 +1,374 @@ +defmodule Smolquery.Auth.OIDC.TokenTest do + use ExUnit.Case, async: true + + alias Smolquery.Auth.OIDC.{Config, Provider, Token} + + @now 1_700_000_000 + @private_key JOSE.JWK.generate_key({:rsa, 2048}) + @public_key JOSE.JWK.to_map(JOSE.JWK.to_public(@private_key)) + |> elem(1) + |> Map.put("kid", "one") + @jwks %{"keys" => [@public_key]} + @metadata %{ + "issuer" => "https://issuer.example", + "authorization_endpoint" => "https://issuer.example/authorize", + "token_endpoint" => "https://issuer.example/token", + "jwks_uri" => "https://issuer.example/keys", + "id_token_signing_alg_values_supported" => ["RS256"] + } + @base_config Config.new( + [ + oidc: [ + issuer: "https://issuer.example", + api_audience: "smolquery-api", + claim_capabilities: %{ + "scope" => %{ + "query" => [:query], + "writer" => [:ingest], + "admin" => [:catalog_manage] + } + } + ] + ], + :api + ) + + test "verifies and normalizes a signed token" do + claims = + valid_claims(%{"scope" => ["query", "writer", "admin"], "name" => "Ada", "azp" => "web"}) + + assert {:ok, context} = Token.verify(token(claims), @base_config, @jwks, %{}, now: @now) + assert context.principal.authn == :oidc + assert context.principal.subject == "subject-1" + assert context.principal.display_name == "Ada" + assert context.principal.client_id == "web" + assert MapSet.equal?(context.capabilities, MapSet.new([:query, :ingest, :catalog_manage])) + assert context.expires_at == claims["exp"] + @base_config.clock_skew + refute inspect(context) =~ token(claims) + end + + test "requires exact issuer, audience, subject, and integer expiration" do + for change <- [ + {"iss", "https://other.example"}, + {"aud", "other"}, + {"sub", ""}, + {"exp", "later"}, + {"exp", true}, + {"exp", 1.2} + ] do + assert :error = + Token.verify( + token(valid_claims(%{elem(change, 0) => elem(change, 1)})), + @base_config, + @jwks, + %{}, + now: @now + ) + end + + assert :error = + Token.verify(token(Map.delete(valid_claims(), "sub")), @base_config, @jwks, %{}, + now: @now + ) + + assert :error = + Token.verify(token(Map.delete(valid_claims(), "exp")), @base_config, @jwks, %{}, + now: @now + ) + end + + test "applies expiration, not-before, and issued-at boundaries" do + config = %{@base_config | clock_skew: 10, iat_future_seconds: 20} + + assert {:ok, _} = + Token.verify(token(valid_claims(%{"exp" => @now - 1})), config, @jwks, %{}, + now: @now + ) + + assert :error = + Token.verify(token(valid_claims(%{"exp" => @now - 10})), config, @jwks, %{}, + now: @now + ) + + assert {:ok, _} = + Token.verify(token(valid_claims(%{"nbf" => @now + 10})), config, @jwks, %{}, + now: @now + ) + + assert :error = + Token.verify(token(valid_claims(%{"nbf" => @now + 11})), config, @jwks, %{}, + now: @now + ) + + assert :error = + Token.verify(token(valid_claims(%{"nbf" => -1})), config, @jwks, %{}, now: @now) + + assert {:ok, _} = + Token.verify(token(valid_claims(%{"iat" => @now + 20})), config, @jwks, %{}, + now: @now + ) + + assert :error = + Token.verify(token(valid_claims(%{"iat" => @now + 21})), config, @jwks, %{}, + now: @now + ) + + assert :error = + Token.verify(token(valid_claims(%{"iat" => "future"})), config, @jwks, %{}, + now: @now + ) + end + + test "requires a bounded kid and rejects duplicate or incompatible keys" do + no_kid = token(valid_claims(), %{"alg" => "RS256"}) + assert :error = Token.verify(no_kid, @base_config, @jwks, %{}, now: @now) + + duplicate = %{"keys" => [@public_key, @public_key]} + assert :error = Token.verify(token(valid_claims()), @base_config, duplicate, %{}, now: @now) + + wrong_use = Map.put(@public_key, "use", "enc") + + assert :error = + Token.verify(token(valid_claims()), @base_config, %{"keys" => [wrong_use]}, %{}, + now: @now + ) + + wrong_type = Map.put(@public_key, "kty", "EC") + + assert :error = + Token.verify(token(valid_claims()), @base_config, %{"keys" => [wrong_type]}, %{}, + now: @now + ) + end + + test "authenticate uses the supervised provider path to accept one real key rotation" do + rotated_key = JOSE.JWK.generate_key({:rsa, 2048}) + + rotated_public = + JOSE.JWK.to_map(JOSE.JWK.to_public(rotated_key)) |> elem(1) |> Map.put("kid", "two") + + rotated_token = token(valid_claims(), %{"alg" => "RS256", "kid" => "two"}, rotated_key) + {:ok, counter} = Agent.start_link(fn -> 0 end) + + client = fn url, _options -> + case url do + "https://issuer.example/.well-known/openid-configuration" -> + {:ok, response(@metadata)} + + "https://issuer.example/keys" -> + count = Agent.get_and_update(counter, fn value -> {value, value + 1} end) + {:ok, response(if(count == 0, do: @jwks, else: %{"keys" => [rotated_public]}))} + end + end + + config = %{@base_config | forced_refresh_cooldown_ms: 100_000} + {:ok, pid} = Provider.start_link(name: unique_name(), config: config, http_client: client) + assert {:ok, _context} = Token.authenticate(rotated_token, config, pid, now: @now) + assert Agent.get(counter, & &1) == 2 + Process.exit(pid, :normal) + end + + test "authenticate rejects stale unknown keys during refresh outage and cooldown" do + rotated_key = JOSE.JWK.generate_key({:rsa, 2048}) + rotated_token = token(valid_claims(), %{"alg" => "RS256", "kid" => "two"}, rotated_key) + {:ok, counter} = Agent.start_link(fn -> 0 end) + + client = fn url, _options -> + case url do + "https://issuer.example/.well-known/openid-configuration" -> + {:ok, response(@metadata)} + + "https://issuer.example/keys" -> + count = Agent.get_and_update(counter, fn value -> {value, value + 1} end) + if count == 0, do: {:ok, response(@jwks)}, else: {:error, :timeout} + end + end + + config = %{@base_config | forced_refresh_cooldown_ms: 100_000} + {:ok, pid} = Provider.start_link(name: unique_name(), config: config, http_client: client) + assert :error = Token.authenticate(rotated_token, config, pid, now: @now) + assert :error = Token.authenticate(rotated_token, config, pid, now: @now) + assert Agent.get(counter, & &1) == 2 + Process.exit(pid, :normal) + end + + test "refreshes once for an unknown kid and fails when refresh has no key" do + rotated_key = JOSE.JWK.generate_key({:rsa, 2048}) + + rotated_public = + JOSE.JWK.to_map(JOSE.JWK.to_public(rotated_key)) |> elem(1) |> Map.put("kid", "two") + + rotated_token = token(valid_claims(), %{"alg" => "RS256", "kid" => "two"}, rotated_key) + + assert {:ok, _context} = + Token.verify(rotated_token, @base_config, @jwks, %{"keys" => [rotated_public]}, + now: @now + ) + + assert :error = + Token.verify(rotated_token, @base_config, @jwks, %{"keys" => []}, now: @now) + end + + test "rejects invalid signatures and algorithms" do + other_key = JOSE.JWK.generate_key({:rsa, 2048}) + + assert :error = + Token.verify( + token(valid_claims(), %{"alg" => "RS256", "kid" => "one"}, other_key), + @base_config, + @jwks, + %{}, + now: @now + ) + + assert :error = + Token.verify( + replace_header(token(valid_claims()), %{"alg" => "HS256", "kid" => "one"}), + @base_config, + @jwks, + %{}, + now: @now + ) + + rs384_config = %{@base_config | algorithms: ["RS256"]} + + assert :error = + Token.verify( + token(valid_claims(), %{"alg" => "RS384", "kid" => "one"}), + rs384_config, + @jwks, + %{}, + now: @now + ) + + malformed = "not.a.jwt" + assert :error = Token.verify(malformed, @base_config, @jwks, %{}, now: @now) + end + + test "rejects prohibited protected headers and malformed optional typ" do + for extra <- [ + %{"jku" => "https://attacker.example/key"}, + %{"jwk" => %{}}, + %{"x5u" => "https://attacker.example/cert"}, + %{"crit" => ["exp"]}, + %{"b64" => false}, + %{"typ" => ""}, + %{"typ" => 42} + ] do + header = Map.merge(%{"alg" => "RS256", "kid" => "one"}, extra) + + assert :error = + Token.verify(token(valid_claims(), header), @base_config, @jwks, %{}, now: @now) + end + end + + test "rejects empty audience members and does not partially grant malformed list claims" do + assert :error = + Token.verify( + token(valid_claims(%{"aud" => ["", "smolquery-api"]})), + @base_config, + @jwks, + %{}, + now: @now + ) + + claims = valid_claims(%{"scope" => ["query", 42, "admin"]}) + assert {:ok, context} = Token.verify(token(claims), @base_config, @jwks, %{}, now: @now) + assert MapSet.equal?(context.capabilities, MapSet.new()) + end + + test "enforces optional type and required claim values" do + config = %{ + @base_config + | typ_allowlist: ["at+jwt"], + required_claims: %{"token_use" => ["access"]} + } + + assert {:ok, _} = + Token.verify( + token(valid_claims(%{"token_use" => "access"}), %{ + "alg" => "RS256", + "kid" => "one", + "typ" => "at+jwt" + }), + config, + @jwks, + %{}, + now: @now + ) + + assert :error = + Token.verify( + token(valid_claims(%{"token_use" => "id"}), %{ + "alg" => "RS256", + "kid" => "one", + "typ" => "at+jwt" + }), + config, + @jwks, + %{}, + now: @now + ) + + assert :error = + Token.verify(token(valid_claims(%{"token_use" => "access"})), config, @jwks, %{}, + now: @now + ) + end + + test "rejects oversized compact tokens before JOSE decoding" do + config = %{@base_config | max_token_bytes: 100} + + assert :error = + Token.verify( + token(valid_claims(%{"padding" => String.duplicate("x", 200)})), + config, + @jwks, + %{}, + now: @now + ) + + segment_config = %{@base_config | max_token_bytes: 10_000, max_segment_bytes: 100} + + assert :error = + Token.verify( + token(valid_claims(%{"padding" => String.duplicate("x", 200)})), + segment_config, + @jwks, + %{}, + now: @now + ) + end + + defp unique_name, do: {:global, {__MODULE__, System.unique_integer([:positive])}} + + defp response(body) do + %Req.Response{ + status: 200, + headers: %{"content-type" => ["application/json"]}, + body: JSON.encode!(body) + } + end + + defp valid_claims(overrides \\ %{}), + do: + Map.merge( + %{ + "iss" => "https://issuer.example", + "aud" => "smolquery-api", + "sub" => "subject-1", + "exp" => @now + 100 + }, + overrides + ) + + defp replace_header(token, header) do + [_old_header, payload, signature] = String.split(token, ".", trim: false) + encoded = Base.url_encode64(JSON.encode!(header), padding: false) + Enum.join([encoded, payload, signature], ".") + end + + defp token(claims, header \\ %{"alg" => "RS256", "kid" => "one"}, key \\ @private_key) do + JOSE.JWT.sign(key, JOSE.JWS.from_map(header), claims) |> JOSE.JWS.compact() |> elem(1) + end +end diff --git a/test/smolquery/runtime_config_test.exs b/test/smolquery/runtime_config_test.exs index 157d83da..b4481d6f 100644 --- a/test/smolquery/runtime_config_test.exs +++ b/test/smolquery/runtime_config_test.exs @@ -93,6 +93,10 @@ defmodule Smolquery.RuntimeConfigTest do assert RuntimeConfig.csv!("ALGORITHMS", "RS256, ES256") == ["RS256", "ES256"] assert RuntimeConfig.bounded_non_negative_integer!("SKEW", "30", 300) == 30 + assert RuntimeConfig.string_lists!("REQUIRED", ~s({"token_use":["access"]})) == %{ + "token_use" => ["access"] + } + assert RuntimeConfig.capability_mapping!( "CLAIMS", ~s({"roles":{"reader":["query"],"operator":["web_access","platform_operate"]}}) @@ -102,10 +106,11 @@ defmodule Smolquery.RuntimeConfigTest do for {function, value} <- [ {&RuntimeConfig.csv!("ALGORITHMS", &1), ""}, + {&RuntimeConfig.string_lists!("REQUIRED", &1), "[]"}, {&RuntimeConfig.capability_mapping!("CLAIMS", &1), ""}, {&RuntimeConfig.capability_mapping!("CLAIMS", &1), "roles=unknown"} ] do - assert_raise ArgumentError, ~r/ALGORITHMS|CLAIMS/, fn -> function.(value) end + assert_raise ArgumentError, ~r/ALGORITHMS|CLAIMS|REQUIRED/, fn -> function.(value) end end end @@ -138,7 +143,13 @@ defmodule Smolquery.RuntimeConfigTest do "SMOLQUERY_OIDC_ALGORITHMS" => "RS256,PS256", "SMOLQUERY_OIDC_CLOCK_SKEW" => "30", "SMOLQUERY_OIDC_CLAIM_CAPABILITIES" => - ~s({"roles":{"reader":["query"],"operator":["web_access"]}}) + ~s({"roles":{"reader":["query"],"operator":["web_access"]}}), + "SMOLQUERY_OIDC_TOKEN_TYPES" => "at+jwt", + "SMOLQUERY_OIDC_REQUIRED_CLAIMS" => ~s({"token_use":["access"]}), + "SMOLQUERY_OIDC_MAX_TOKEN_BYTES" => "2048", + "SMOLQUERY_OIDC_MAX_TOKEN_SEGMENT_BYTES" => "1024", + "SMOLQUERY_OIDC_IAT_FUTURE_SECONDS" => "60", + "SMOLQUERY_OIDC_FORCED_REFRESH_COOLDOWN_MS" => "250" }, fn -> runtime = Config.Reader.read!("config/runtime.exs", env: :prod, target: :host) @@ -146,6 +157,12 @@ defmodule Smolquery.RuntimeConfigTest do assert oidc[:issuer] == "https://issuer.example/" assert oidc[:algorithms] == ["RS256", "PS256"] + assert oidc[:typ_allowlist] == ["at+jwt"] + assert oidc[:required_claims] == %{"token_use" => ["access"]} + assert oidc[:max_token_bytes] == 2048 + assert oidc[:max_segment_bytes] == 1024 + assert oidc[:iat_future_seconds] == 60 + assert oidc[:forced_refresh_cooldown_ms] == 250 assert oidc[:claim_capabilities] == %{ "roles" => %{"reader" => [:query], "operator" => [:web_access]} diff --git a/test/smolquery_api/oidc_auth_test.exs b/test/smolquery_api/oidc_auth_test.exs new file mode 100644 index 00000000..60e1f20b --- /dev/null +++ b/test/smolquery_api/oidc_auth_test.exs @@ -0,0 +1,117 @@ +defmodule SmolqueryApi.OIDCAuthTest do + use ExUnit.Case, async: true + + import Plug.Conn, only: [put_req_header: 3] + import Plug.Test + + alias Smolquery.Auth + alias Smolquery.Auth.OIDC.Provider + alias Smolquery.Test.ApiEndpoint + alias SmolqueryApi.Runtime + + @private_key JOSE.JWK.generate_key({:rsa, 2048}) + @public_key JOSE.JWK.to_map(JOSE.JWK.to_public(@private_key)) + |> elem(1) + |> Map.put("kid", "one") + + test "authenticates OIDC requests before parsing and assigns a normalized context" do + name = start_api() + token = token(%{"scope" => ["query", "ingest", "catalog"]}) + + response = request(name, conn(:get, "/v1/no/such/route") |> bearer(token)) + + assert response.status == 404 + assert {:ok, context} = Auth.fetch_context(response) + assert context.principal.authn == :oidc + assert MapSet.equal?(context.capabilities, MapSet.new([:query, :ingest, :catalog_manage])) + refute inspect(context) =~ token + end + + test "rejects query-only OIDC tokens and obscures route existence without parsing bodies" do + name = start_api() + token = token(%{"scope" => "query"}) + + real = request(name, conn(:post, "/v1/datasets", "not-json") |> bearer(token)) + absent = request(name, conn(:post, "/v1/no/such/route", "not-json") |> bearer(token)) + + assert real.status == 401 + assert real.resp_body == absent.resp_body + assert %Plug.Conn.Unfetched{aspect: :body_params} = real.body_params + assert %Plug.Conn.Unfetched{aspect: :body_params} = absent.body_params + end + + defp start_api do + name = :"api_oidc_#{System.unique_integer([:positive])}" + + runtime = + Runtime.new( + name: name, + auth_mode: :oidc, + oidc: [ + issuer: "https://issuer.example", + api_audience: "smolquery-api", + claim_capabilities: %{ + "scope" => %{ + "query" => [:query], + "ingest" => [:ingest], + "catalog" => [:catalog_manage] + } + } + ] + ) + + client = fn url, _options -> {:ok, response_for(url)} end + provider_name = Module.concat(name, "OIDCProvider") + + {:ok, provider} = + Provider.start_link(name: provider_name, config: runtime.oidc, http_client: client) + + Runtime.put(runtime) + + on_exit(fn -> + Runtime.delete(name) + if Process.alive?(provider), do: GenServer.stop(provider) + end) + + name + end + + defp request(name, conn), do: ApiEndpoint.request(name, conn) + defp bearer(conn, token), do: put_req_header(conn, "authorization", "Bearer #{token}") + + defp token(extra_claims) do + claims = + Map.merge( + %{ + "iss" => "https://issuer.example", + "aud" => "smolquery-api", + "sub" => "subject-1", + "exp" => System.system_time(:second) + 100 + }, + extra_claims + ) + + jws = JOSE.JWS.from_map(%{"alg" => "RS256", "kid" => "one"}) + JOSE.JWT.sign(@private_key, jws, claims) |> JOSE.JWS.compact() |> elem(1) + end + + defp response_for("https://issuer.example/.well-known/openid-configuration") do + response(%{ + "issuer" => "https://issuer.example", + "authorization_endpoint" => "https://issuer.example/authorize", + "token_endpoint" => "https://issuer.example/token", + "jwks_uri" => "https://issuer.example/keys", + "id_token_signing_alg_values_supported" => ["RS256"] + }) + end + + defp response_for("https://issuer.example/keys"), + do: response(%{"keys" => [@public_key]}) + + defp response(body), + do: %Req.Response{ + status: 200, + headers: %{"content-type" => ["application/json"]}, + body: JSON.encode!(body) + } +end diff --git a/test/smolquery_api/router_test.exs b/test/smolquery_api/router_test.exs index 4a3d5e88..a1e177ac 100644 --- a/test/smolquery_api/router_test.exs +++ b/test/smolquery_api/router_test.exs @@ -32,6 +32,18 @@ defmodule SmolqueryApi.RouterTest do assert response.status == 200 assert JSON.decode!(response.resp_body) == %{"status" => "ok"} end + + test "POST healthz requires auth and does not parse valid or malformed bodies" do + name = start_api() + valid = request(name, conn(:post, "/healthz", "{}")) + malformed = request(name, conn(:post, "/healthz", "not-json")) + + assert valid.status == 401 + assert malformed.status == 401 + assert valid.resp_body == malformed.resp_body + assert %Plug.Conn.Unfetched{aspect: :body_params} = valid.body_params + assert %Plug.Conn.Unfetched{aspect: :body_params} = malformed.body_params + end end describe "metrics" do @@ -80,7 +92,7 @@ defmodule SmolqueryApi.RouterTest do assert %{"error" => %{"code" => 401, "status" => "UNAUTHENTICATED", "message" => message}} = JSON.decode!(response.resp_body) - assert message =~ "API key" + assert message == "missing or invalid API credential" end test "a correct key attaches the normalized service context" do From 6e81121b0bb4c04fee18b6182605d0e14fdd1f5f Mon Sep 17 00:00:00 2001 From: David Whittington Date: Sat, 15 Aug 2026 19:46:26 -0500 Subject: [PATCH 2/4] fix(auth): separate API and web token profiles T-232 adds role-specific token type and required-claim policies, rejects an API audience that aliases the browser client id, and keeps the global settings only as backward-compatible defaults. --- config/runtime.exs | 22 ++++++++ docs/configuration.md | 14 +++-- lib/smolquery/auth/oidc/config.ex | 71 +++++++++++++++++++++--- test/smolquery/auth/oidc/config_test.exs | 38 +++++++++++++ test/smolquery/runtime_config_test.exs | 16 ++++-- 5 files changed, 144 insertions(+), 17 deletions(-) diff --git a/config/runtime.exs b/config/runtime.exs index 1eba00f2..bed231d3 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -69,12 +69,34 @@ if token_types = System.get_env("SMOLQUERY_OIDC_TOKEN_TYPES") do typ_allowlist: Smolquery.RuntimeConfig.csv!("SMOLQUERY_OIDC_TOKEN_TYPES", token_types) end +for {env, key} <- [ + {"SMOLQUERY_OIDC_API_TOKEN_TYPES", :api_typ_allowlist}, + {"SMOLQUERY_OIDC_WEB_TOKEN_TYPES", :web_typ_allowlist} + ] do + if token_types = System.get_env(env) do + config :smolquery, Smolquery.Auth.OIDC.Config, [ + {key, Smolquery.RuntimeConfig.csv!(env, token_types)} + ] + end +end + if required_claims = System.get_env("SMOLQUERY_OIDC_REQUIRED_CLAIMS") do config :smolquery, Smolquery.Auth.OIDC.Config, required_claims: Smolquery.RuntimeConfig.string_lists!("SMOLQUERY_OIDC_REQUIRED_CLAIMS", required_claims) end +for {env, key} <- [ + {"SMOLQUERY_OIDC_API_REQUIRED_CLAIMS", :api_required_claims}, + {"SMOLQUERY_OIDC_WEB_REQUIRED_CLAIMS", :web_required_claims} + ] do + if required_claims = System.get_env(env) do + config :smolquery, Smolquery.Auth.OIDC.Config, [ + {key, Smolquery.RuntimeConfig.string_lists!(env, required_claims)} + ] + end +end + for {env, key, max} <- [ {"SMOLQUERY_OIDC_MAX_TOKEN_BYTES", :max_token_bytes, 1_048_576}, {"SMOLQUERY_OIDC_MAX_TOKEN_SEGMENT_BYTES", :max_segment_bytes, 524_288}, diff --git a/docs/configuration.md b/docs/configuration.md index 1c89fd6a..4be0f17f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -38,8 +38,8 @@ outage or malformed discovery/JWKS never opens either listener. | variable | effect | |---|---| | `SMOLQUERY_OIDC_ISSUER` | exact HTTPS issuer string; trailing slash is retained, while query, fragment, and userinfo are rejected | -| `SMOLQUERY_OIDC_API_AUDIENCE` | required API access-token audience on `:api` roles | -| `SMOLQUERY_OIDC_WEB_CLIENT_ID` | required browser client id on `:web` roles | +| `SMOLQUERY_OIDC_API_AUDIENCE` | required API access-token audience on `:api` roles; when a browser client id is configured, the two values must differ | +| `SMOLQUERY_OIDC_WEB_CLIENT_ID` | required browser client id on `:web` roles; it cannot alias the API resource audience | | `SMOLQUERY_OIDC_WEB_CLIENT_SECRET` | required only with `SMOLQUERY_OIDC_WEB_CLIENT_AUTH_METHOD=client_secret_basic`; never shown by runtime inspection | | `SMOLQUERY_OIDC_WEB_CLIENT_AUTH_METHOD` | `client_secret_basic` (default) or `none` | | `SMOLQUERY_OIDC_WEB_ORIGIN` | exact HTTPS public browser origin | @@ -47,8 +47,10 @@ outage or malformed discovery/JWKS never opens either listener. | `SMOLQUERY_OIDC_ALGORITHMS` | comma-separated local allowlist (default `RS256`); token or discovery metadata never expands it | | `SMOLQUERY_OIDC_CLOCK_SKEW` | bounded non-negative seconds for later token validation (default `30`) | | `SMOLQUERY_OIDC_CLAIM_CAPABILITIES` | optional JSON object mapping claim names to exact string values and capability arrays, e.g. `{"roles":{"reader":["query"],"operator":["web_access","query","platform_operate"]}}`; list-valued token claims union matching values | -| `SMOLQUERY_OIDC_TOKEN_TYPES` | optional comma-separated protected-header `typ` allowlist; when set, a token must carry one exact type | -| `SMOLQUERY_OIDC_REQUIRED_CLAIMS` | optional JSON object mapping required payload claim names to allowed exact string values, e.g. `{"token_use":["access"]}` | +| `SMOLQUERY_OIDC_API_TOKEN_TYPES` / `SMOLQUERY_OIDC_WEB_TOKEN_TYPES` | optional role-specific comma-separated protected-header `typ` allowlists; use these when API access tokens and browser ID tokens carry different types | +| `SMOLQUERY_OIDC_TOKEN_TYPES` | backward-compatible common `typ` allowlist used only when the role-specific setting is absent | +| `SMOLQUERY_OIDC_API_REQUIRED_CLAIMS` / `SMOLQUERY_OIDC_WEB_REQUIRED_CLAIMS` | optional role-specific JSON objects mapping required payload claim names to allowed exact string values, e.g. `{"token_use":["access"]}` and `{"token_use":["id"]}` | +| `SMOLQUERY_OIDC_REQUIRED_CLAIMS` | backward-compatible common required-claim map used only when the role-specific setting is absent | | `SMOLQUERY_OIDC_MAX_TOKEN_BYTES` / `SMOLQUERY_OIDC_MAX_TOKEN_SEGMENT_BYTES` | bounds compact token and individual encoded segments before JOSE decoding (defaults `65536` / `32768`) | | `SMOLQUERY_OIDC_IAT_FUTURE_SECONDS` | maximum future `iat` allowance (default `300`); `exp` contexts remain active through the configured clock-skew boundary | | `SMOLQUERY_OIDC_DISCOVERY_MAX_AGE_MS` / `SMOLQUERY_OIDC_JWKS_MAX_AGE_MS` | bounded cache freshness windows (defaults `3600000`) | @@ -59,7 +61,9 @@ outage or malformed discovery/JWKS never opens either listener. The API verifier requires a non-empty `kid`, a locally allowlisted asymmetric algorithm, a compatible public signing key, exact issuer and audience, and -integer NumericDate claims. Unknown keys trigger one supervised JWKS refresh, subject to the global forced-refresh cooldown; +integer NumericDate claims. API and web token type/required-claim profiles are +resolved separately, and a combined deployment refuses to use its browser +client id as the API audience. Unknown keys trigger one supervised JWKS refresh, subject to the global forced-refresh cooldown; concurrent or repeated unknown keys within that cooldown reuse the current cache and reject without another network fetch. All other failures reject without revealing the verification reason. The context expiry is `exp + SMOLQUERY_OIDC_CLOCK_SKEW`, matching the accepted diff --git a/lib/smolquery/auth/oidc/config.ex b/lib/smolquery/auth/oidc/config.ex index d8672922..8cf7b92e 100644 --- a/lib/smolquery/auth/oidc/config.ex +++ b/lib/smolquery/auth/oidc/config.ex @@ -104,16 +104,14 @@ defmodule Smolquery.Auth.OIDC.Config do web_client_id = required_for_role(config, :web_client_id, role, "SMOLQUERY_OIDC_WEB_CLIENT_ID") + :ok = distinct_audiences(config) {web_origin, web_redirect_uri} = web_urls(config, role) auth_method = config |> Keyword.fetch!(:web_client_auth_method) |> auth_method!() web_client_secret = client_secret(config, auth_method, role) algorithms = algorithms!(Keyword.fetch!(config, :algorithms)) claim_capabilities = claim_capabilities!(Keyword.fetch!(config, :claim_capabilities)) - typ_allowlist = - string_allowlist!(Keyword.fetch!(config, :typ_allowlist), "SMOLQUERY_OIDC_TOKEN_TYPES") - - required_claims = required_claims!(Keyword.fetch!(config, :required_claims)) + {typ_allowlist, required_claims} = token_profile(config, role) %__MODULE__{ issuer: issuer, @@ -250,20 +248,22 @@ defmodule Smolquery.Auth.OIDC.Config do @doc "Validates exact required payload claim values." @spec required_claims!(term()) :: required_claims() - def required_claims!(claims) when is_map(claims) do + def required_claims!(claims), do: required_claims!(claims, "SMOLQUERY_OIDC_REQUIRED_CLAIMS") + + defp required_claims!(claims, env) when is_map(claims) do Enum.reduce(claims, %{}, fn {claim, values}, acc -> if nonempty_string?(claim) and is_list(values) and values != [] and Enum.all?(values, &nonempty_string?/1) and length(values) == length(Enum.uniq(values)) do Map.put(acc, claim, values) else - invalid!("SMOLQUERY_OIDC_REQUIRED_CLAIMS", claims, "a map of claim names to string lists") + invalid!(env, claims, "a map of claim names to string lists") end end) end - def required_claims!(value), - do: invalid!("SMOLQUERY_OIDC_REQUIRED_CLAIMS", value, "a map of claim names to string lists") + defp required_claims!(value, env), + do: invalid!(env, value, "a map of claim names to string lists") @spec raise_invalid_claim_mapping(term()) :: no_return() defp raise_invalid_claim_mapping(mapping), @@ -278,6 +278,61 @@ defmodule Smolquery.Auth.OIDC.Config do defp required_for_role(_config, _key, _role, _env), do: nil + defp distinct_audiences(config) do + api_audience = Keyword.get(config, :api_audience) + web_client_id = Keyword.get(config, :web_client_id) + + if nonempty_string?(api_audience) and api_audience == web_client_id do + invalid!( + "SMOLQUERY_OIDC_API_AUDIENCE", + api_audience, + "a resource audience different from SMOLQUERY_OIDC_WEB_CLIENT_ID" + ) + end + + :ok + end + + defp token_profile(config, role) do + {type_key, type_env, claims_key, claims_env} = + case role do + :api -> + {:api_typ_allowlist, "SMOLQUERY_OIDC_API_TOKEN_TYPES", :api_required_claims, + "SMOLQUERY_OIDC_API_REQUIRED_CLAIMS"} + + :web -> + {:web_typ_allowlist, "SMOLQUERY_OIDC_WEB_TOKEN_TYPES", :web_required_claims, + "SMOLQUERY_OIDC_WEB_REQUIRED_CLAIMS"} + end + + {types, type_env} = + role_profile_value( + config, + type_key, + :typ_allowlist, + type_env, + "SMOLQUERY_OIDC_TOKEN_TYPES" + ) + + {claims, claims_env} = + role_profile_value( + config, + claims_key, + :required_claims, + claims_env, + "SMOLQUERY_OIDC_REQUIRED_CLAIMS" + ) + + {string_allowlist!(types, type_env), required_claims!(claims, claims_env)} + end + + defp role_profile_value(config, role_key, global_key, role_env, global_env) do + case Keyword.fetch(config, role_key) do + {:ok, value} -> {value, role_env} + :error -> {Keyword.fetch!(config, global_key), global_env} + end + end + defp web_urls(_config, :api), do: {nil, nil} defp web_urls(config, :web) do diff --git a/test/smolquery/auth/oidc/config_test.exs b/test/smolquery/auth/oidc/config_test.exs index 13675294..b39e737c 100644 --- a/test/smolquery/auth/oidc/config_test.exs +++ b/test/smolquery/auth/oidc/config_test.exs @@ -153,6 +153,44 @@ defmodule Smolquery.Auth.OIDC.ConfigTest do end end + test "resolves separate API and web token profiles with global fallbacks" do + options = + Keyword.merge(@base, + typ_allowlist: ["legacy+jwt"], + required_claims: %{"legacy" => ["true"]}, + api_typ_allowlist: ["at+jwt"], + api_required_claims: %{"token_use" => ["access"]}, + web_typ_allowlist: ["JWT"], + web_required_claims: %{"token_use" => ["id"]} + ) + + api = Config.new([oidc: options], :api) + web = Config.new([oidc: options], :web) + + assert api.typ_allowlist == ["at+jwt"] + assert api.required_claims == %{"token_use" => ["access"]} + assert web.typ_allowlist == ["JWT"] + assert web.required_claims == %{"token_use" => ["id"]} + + fallback = Config.new([oidc: @base ++ [typ_allowlist: ["legacy+jwt"]]], :api) + assert fallback.typ_allowlist == ["legacy+jwt"] + + assert_raise ArgumentError, ~r/SMOLQUERY_OIDC_API_TOKEN_TYPES/, fn -> + invalid = Keyword.put(options, :api_typ_allowlist, ["at+jwt", "at+jwt"]) + Config.new([oidc: invalid], :api) + end + end + + test "rejects an API audience that aliases the browser client id" do + options = Keyword.put(@base, :api_audience, "smolquery-web") + + for role <- [:api, :web] do + assert_raise ArgumentError, ~r/SMOLQUERY_OIDC_API_AUDIENCE.*different/, fn -> + Config.new([oidc: options], role) + end + end + end + test "redacts the client secret" do config = Config.new([oidc: @base], :web) refute inspect(config) =~ "\"secret\"" diff --git a/test/smolquery/runtime_config_test.exs b/test/smolquery/runtime_config_test.exs index 3d44438b..b1488d5e 100644 --- a/test/smolquery/runtime_config_test.exs +++ b/test/smolquery/runtime_config_test.exs @@ -145,8 +145,12 @@ defmodule Smolquery.RuntimeConfigTest do "SMOLQUERY_OIDC_REFRESH_FAILURE_BACKOFF_MS" => "250", "SMOLQUERY_OIDC_CLAIM_CAPABILITIES" => ~s({"roles":{"reader":["query"],"operator":["web_access"]}}), - "SMOLQUERY_OIDC_TOKEN_TYPES" => "at+jwt", - "SMOLQUERY_OIDC_REQUIRED_CLAIMS" => ~s({"token_use":["access"]}), + "SMOLQUERY_OIDC_TOKEN_TYPES" => "legacy+jwt", + "SMOLQUERY_OIDC_API_TOKEN_TYPES" => "at+jwt", + "SMOLQUERY_OIDC_WEB_TOKEN_TYPES" => "JWT", + "SMOLQUERY_OIDC_REQUIRED_CLAIMS" => ~s({"legacy":["true"]}), + "SMOLQUERY_OIDC_API_REQUIRED_CLAIMS" => ~s({"token_use":["access"]}), + "SMOLQUERY_OIDC_WEB_REQUIRED_CLAIMS" => ~s({"token_use":["id"]}), "SMOLQUERY_OIDC_MAX_TOKEN_BYTES" => "2048", "SMOLQUERY_OIDC_MAX_TOKEN_SEGMENT_BYTES" => "1024", "SMOLQUERY_OIDC_IAT_FUTURE_SECONDS" => "60", @@ -158,8 +162,12 @@ defmodule Smolquery.RuntimeConfigTest do assert oidc[:issuer] == "https://issuer.example/" assert oidc[:algorithms] == ["RS256", "PS256"] - assert oidc[:typ_allowlist] == ["at+jwt"] - assert oidc[:required_claims] == %{"token_use" => ["access"]} + assert oidc[:typ_allowlist] == ["legacy+jwt"] + assert oidc[:api_typ_allowlist] == ["at+jwt"] + assert oidc[:web_typ_allowlist] == ["JWT"] + assert oidc[:required_claims] == %{"legacy" => ["true"]} + assert oidc[:api_required_claims] == %{"token_use" => ["access"]} + assert oidc[:web_required_claims] == %{"token_use" => ["id"]} assert oidc[:max_token_bytes] == 2048 assert oidc[:max_segment_bytes] == 1024 assert oidc[:iat_future_seconds] == 60 From c8b30fa26c477d7ee2e5e35ec9a99fd53a6dba51 Mon Sep 17 00:00:00 2001 From: David Whittington Date: Sun, 16 Aug 2026 10:50:00 -0500 Subject: [PATCH 3/4] fix(auth): reject browser ID tokens at the API boundary Reject API bearer tokens whose audience list contains the configured browser client ID, while preserving authorized-party metadata on legitimate resource access tokens. Cover the signed multi-audience ID-token confusion case and document the deployment binding. Tests: ../bin/test test/smolquery/auth/oidc/token_test.exs Refs T-232 and PL-27. --- lib/smolquery/auth/oidc/token.ex | 13 ++++++++++++ test/smolquery/auth/oidc/token_test.exs | 27 +++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/lib/smolquery/auth/oidc/token.ex b/lib/smolquery/auth/oidc/token.ex index b6ed6d20..12633f39 100644 --- a/lib/smolquery/auth/oidc/token.ex +++ b/lib/smolquery/auth/oidc/token.ex @@ -204,6 +204,7 @@ defmodule Smolquery.Auth.OIDC.Token do with :ok <- required_claims(claims, config.required_claims), :ok <- exact_issuer(claims, config.issuer), :ok <- valid_audience(claims, config.api_audience), + :ok <- exclude_browser_audience(claims, config.web_client_id), {:ok, subject} <- required_string(claims, "sub"), {:ok, expires_at} <- valid_expiry(claims, config.clock_skew, now), :ok <- valid_not_before(claims, config.clock_skew, now), @@ -254,6 +255,18 @@ defmodule Smolquery.Auth.OIDC.Token do defp valid_audience(_claims, _expected), do: :error + defp exclude_browser_audience(_claims, nil), do: :ok + + defp exclude_browser_audience(%{"aud" => audience}, browser_client_id) + when is_binary(audience), + do: if(audience == browser_client_id, do: :error, else: :ok) + + defp exclude_browser_audience(%{"aud" => audiences}, browser_client_id) + when is_list(audiences), + do: if(browser_client_id in audiences, do: :error, else: :ok) + + defp exclude_browser_audience(_claims, _browser_client_id), do: :error + defp required_string(claims, key) do case Map.get(claims, key) do value when is_binary(value) and value != "" and byte_size(value) <= @max_subject_bytes -> diff --git a/test/smolquery/auth/oidc/token_test.exs b/test/smolquery/auth/oidc/token_test.exs index f50f139b..89ca6bdc 100644 --- a/test/smolquery/auth/oidc/token_test.exs +++ b/test/smolquery/auth/oidc/token_test.exs @@ -277,6 +277,33 @@ defmodule Smolquery.Auth.OIDC.TokenTest do assert MapSet.equal?(context.capabilities, MapSet.new()) end + test "rejects browser ID-token audiences at the API boundary" do + config = %{@base_config | web_client_id: "smolquery-web"} + + browser_id_claims = + valid_claims(%{ + "aud" => ["smolquery-web", "smolquery-api"], + "azp" => "smolquery-web", + "nonce" => "browser-nonce", + "scope" => ["query", "admin"] + }) + + assert :error = + Token.verify(token(browser_id_claims), config, @jwks, %{}, now: @now) + + access_claims = + valid_claims(%{ + "aud" => ["other-resource", "smolquery-api"], + "azp" => "smolquery-web", + "scope" => ["query"] + }) + + assert {:ok, context} = + Token.verify(token(access_claims), config, @jwks, %{}, now: @now) + + assert MapSet.equal?(context.capabilities, MapSet.new([:query])) + end + test "enforces optional type and required claim values" do config = %{ @base_config From 578eb3e80f5ef01fe50c2d3f08b94dbbecd28e1a Mon Sep 17 00:00:00 2001 From: David Whittington Date: Sun, 16 Aug 2026 11:14:35 -0500 Subject: [PATCH 4/4] fix(auth): require an explicit API token boundary Refuse API OIDC startup unless the deployment supplies either the browser client ID to exclude from audiences or an API-specific token type/required-claim profile. Split API/web deployments can no longer silently fall back to audience-only ID-token acceptance. Refs T-232 and PL-27. --- docs/configuration.md | 15 ++++++++------ lib/smolquery/auth/oidc/config.ex | 23 ++++++++++++++++++++++ lib/smolquery/auth/oidc/token.ex | 6 ++++-- lib/smolquery_api/runtime.ex | 3 ++- test/smolquery/auth/oidc/config_test.exs | 16 +++++++++++++++ test/smolquery/auth/oidc/token_test.exs | 10 ++++++---- test/smolquery_api/oidc_auth_test.exs | 1 + test/smolquery_api/runtime_test.exs | 25 +++++++++++++++++++++++- 8 files changed, 85 insertions(+), 14 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 8a83f05c..b0cdcfd6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -40,7 +40,7 @@ outage or malformed discovery/JWKS never opens either listener. |---|---| | `SMOLQUERY_OIDC_ISSUER` | exact HTTPS issuer string; trailing slash is retained, while query, fragment, and userinfo are rejected | | `SMOLQUERY_OIDC_API_AUDIENCE` | required API access-token audience on `:api` roles; it must differ from the browser client id when both are configured | -| `SMOLQUERY_OIDC_WEB_CLIENT_ID` | required browser client id on `:web` roles; optional on API-only roles so the token verifier can reject browser-client audiences | +| `SMOLQUERY_OIDC_WEB_CLIENT_ID` | required browser client id on `:web` roles; on API roles, supply it unless an API-specific token type or required-claim profile distinguishes access tokens | | `SMOLQUERY_OIDC_WEB_CLIENT_SECRET` | required only with `SMOLQUERY_OIDC_WEB_CLIENT_AUTH_METHOD=client_secret_basic`; never shown by runtime inspection | | `SMOLQUERY_OIDC_WEB_CLIENT_AUTH_METHOD` | `client_secret_basic` (default) or `none` | | `SMOLQUERY_OIDC_WEB_ORIGIN` | exact HTTPS public browser origin; its host must match `SMOLQUERY_WEB_HOST` | @@ -49,9 +49,9 @@ outage or malformed discovery/JWKS never opens either listener. | `SMOLQUERY_OIDC_ALGORITHMS` | comma-separated local allowlist (default `RS256`); token or discovery metadata never expands it | | `SMOLQUERY_OIDC_CLOCK_SKEW` | bounded non-negative seconds for later token validation (default `30`) | | `SMOLQUERY_OIDC_CLAIM_CAPABILITIES` | optional JSON object mapping claim names to exact string values and capability arrays, e.g. `{"roles":{"reader":["query"],"operator":["web_access","query","platform_operate"]}}`; list-valued token claims union matching values | -| `SMOLQUERY_OIDC_API_TOKEN_TYPES` / `SMOLQUERY_OIDC_WEB_TOKEN_TYPES` | optional role-specific comma-separated protected-header `typ` allowlists; use these when API access tokens and browser ID tokens carry different types | +| `SMOLQUERY_OIDC_API_TOKEN_TYPES` / `SMOLQUERY_OIDC_WEB_TOKEN_TYPES` | role-specific comma-separated protected-header `typ` allowlists; an API role without the browser client id must configure this or `SMOLQUERY_OIDC_API_REQUIRED_CLAIMS` | | `SMOLQUERY_OIDC_TOKEN_TYPES` | backward-compatible common `typ` allowlist used only when the role-specific setting is absent | -| `SMOLQUERY_OIDC_API_REQUIRED_CLAIMS` / `SMOLQUERY_OIDC_WEB_REQUIRED_CLAIMS` | optional role-specific JSON objects mapping required payload claim names to allowed exact string values, e.g. `{"token_use":["access"]}` and `{"token_use":["id"]}` | +| `SMOLQUERY_OIDC_API_REQUIRED_CLAIMS` / `SMOLQUERY_OIDC_WEB_REQUIRED_CLAIMS` | role-specific JSON objects mapping required payload claim names to allowed exact string values, e.g. `{"token_use":["access"]}` and `{"token_use":["id"]}`; an API role without the browser client id must configure this or `SMOLQUERY_OIDC_API_TOKEN_TYPES` | | `SMOLQUERY_OIDC_REQUIRED_CLAIMS` | backward-compatible common required-claim map used only when the role-specific setting is absent | | `SMOLQUERY_OIDC_MAX_TOKEN_BYTES` / `SMOLQUERY_OIDC_MAX_TOKEN_SEGMENT_BYTES` | bounds compact token and individual encoded segments before JOSE decoding (defaults `65536` / `32768`) | | `SMOLQUERY_OIDC_IAT_FUTURE_SECONDS` | maximum future `iat` allowance (default `300`); `exp` contexts remain active through the configured clock-skew boundary | @@ -64,9 +64,12 @@ outage or malformed discovery/JWKS never opens either listener. The API verifier requires a non-empty `kid`, a locally allowlisted asymmetric algorithm, a compatible public signing key, exact issuer and audience, and integer NumericDate claims. API and web token type/required-claim profiles are -resolved separately. When the browser client ID is configured on an API role, -the verifier rejects any API token whose audience list also contains that client ID, -preventing a browser ID token from crossing the access-token boundary. Unknown keys trigger one supervised JWKS refresh, +resolved separately. An API role refuses to boot unless it has either the browser +client ID to exclude from token audiences or an API-specific token type/required-claim +profile. When configured, the browser client ID must not appear in an API token's +`aud`; providers should identify the authorized client through `azp` or `client_id` +instead. This prevents a browser ID token from crossing the access-token boundary. +Unknown keys trigger one supervised JWKS refresh, subject to the global forced-refresh cooldown; concurrent or repeated unknown keys within that cooldown reuse the current cache and reject without another network fetch. All other failures reject without revealing the verification diff --git a/lib/smolquery/auth/oidc/config.ex b/lib/smolquery/auth/oidc/config.ex index 414276e3..2a05ff8d 100644 --- a/lib/smolquery/auth/oidc/config.ex +++ b/lib/smolquery/auth/oidc/config.ex @@ -238,6 +238,29 @@ defmodule Smolquery.Auth.OIDC.Config do def algorithms, do: @algorithms + @doc "Reports whether configuration separates API access tokens from browser ID tokens." + @spec api_token_boundary?(t()) :: boolean() + def api_token_boundary?(%__MODULE__{ + web_client_id: web_client_id, + typ_allowlist: typ_allowlist, + required_claims: required_claims + }) do + nonempty_string?(web_client_id) or typ_allowlist != [] or map_size(required_claims) > 0 + end + + @doc "Requires an explicit boundary between API access tokens and browser ID tokens." + @spec validate_api_token_boundary!(t()) :: t() + def validate_api_token_boundary!(%__MODULE__{} = config) do + if api_token_boundary?(config) do + config + else + raise ArgumentError, + "API OIDC authentication requires SMOLQUERY_OIDC_WEB_CLIENT_ID, " <> + "SMOLQUERY_OIDC_API_TOKEN_TYPES, or SMOLQUERY_OIDC_API_REQUIRED_CLAIMS " <> + "to distinguish access tokens from browser ID tokens" + end + end + @doc "Returns the closed set of configured protected-header token types." @spec string_allowlist!(term(), String.t()) :: [String.t()] def string_allowlist!([], _env), do: [] diff --git a/lib/smolquery/auth/oidc/token.ex b/lib/smolquery/auth/oidc/token.ex index 12633f39..36dd8294 100644 --- a/lib/smolquery/auth/oidc/token.ex +++ b/lib/smolquery/auth/oidc/token.ex @@ -23,7 +23,8 @@ defmodule Smolquery.Auth.OIDC.Token do def authenticate(token, config, provider, opts \\ []) do now = Keyword.get(opts, :now, System.system_time(:second)) - with {:ok, header} <- parse_header(token, config), + with true <- Config.api_token_boundary?(config), + {:ok, header} <- parse_header(token, config), :ok <- validate_header(header, config), {:ok, jwks} <- provider_jwks(provider), {:ok, jwk} <- select_or_refresh(header, jwks, provider, config), @@ -40,7 +41,8 @@ defmodule Smolquery.Auth.OIDC.Token do def verify(token, config, jwks, refreshed_jwks, opts \\ []) do now = Keyword.get(opts, :now, System.system_time(:second)) - with {:ok, header} <- parse_header(token, config), + with true <- Config.api_token_boundary?(config), + {:ok, header} <- parse_header(token, config), :ok <- validate_header(header, config), {:ok, jwk} <- select_or_refresh_with(header, jwks, refreshed_jwks, config), {:ok, claims} <- verify_claims(token, jwk, config, now), diff --git a/lib/smolquery_api/runtime.ex b/lib/smolquery_api/runtime.ex index f129e693..165eb191 100644 --- a/lib/smolquery_api/runtime.ex +++ b/lib/smolquery_api/runtime.ex @@ -104,7 +104,8 @@ defmodule SmolqueryApi.Runtime do {key, Static.api_context(), nil, nil} :oidc -> - {nil, nil, OIDCConfig.new(config, :api), OIDC.provider_http_client!(config)} + oidc = config |> OIDCConfig.new(:api) |> OIDCConfig.validate_api_token_boundary!() + {nil, nil, oidc, OIDC.provider_http_client!(config)} end %__MODULE__{ diff --git a/test/smolquery/auth/oidc/config_test.exs b/test/smolquery/auth/oidc/config_test.exs index 59c1c730..5010035f 100644 --- a/test/smolquery/auth/oidc/config_test.exs +++ b/test/smolquery/auth/oidc/config_test.exs @@ -220,6 +220,22 @@ defmodule Smolquery.Auth.OIDC.ConfigTest do end end + test "requires an explicit API access-token boundary" do + no_browser_client = Keyword.delete(@base, :web_client_id) + config = Config.new([oidc: no_browser_client], :api) + + assert_raise ArgumentError, ~r/distinguish access tokens from browser ID tokens/, fn -> + Config.validate_api_token_boundary!(config) + end + + typed = + no_browser_client + |> Keyword.put(:api_typ_allowlist, ["at+jwt"]) + |> then(&Config.new([oidc: &1], :api)) + + assert Config.validate_api_token_boundary!(typed) == typed + end + test "rejects an API audience that aliases the browser client id" do options = Keyword.put(@base, :api_audience, "smolquery-web") diff --git a/test/smolquery/auth/oidc/token_test.exs b/test/smolquery/auth/oidc/token_test.exs index 89ca6bdc..f8b3a27c 100644 --- a/test/smolquery/auth/oidc/token_test.exs +++ b/test/smolquery/auth/oidc/token_test.exs @@ -21,6 +21,7 @@ defmodule Smolquery.Auth.OIDC.TokenTest do oidc: [ issuer: "https://issuer.example", api_audience: "smolquery-api", + web_client_id: "smolquery-web", claim_capabilities: %{ "scope" => %{ "query" => [:query], @@ -278,8 +279,6 @@ defmodule Smolquery.Auth.OIDC.TokenTest do end test "rejects browser ID-token audiences at the API boundary" do - config = %{@base_config | web_client_id: "smolquery-web"} - browser_id_claims = valid_claims(%{ "aud" => ["smolquery-web", "smolquery-api"], @@ -289,7 +288,7 @@ defmodule Smolquery.Auth.OIDC.TokenTest do }) assert :error = - Token.verify(token(browser_id_claims), config, @jwks, %{}, now: @now) + Token.verify(token(browser_id_claims), @base_config, @jwks, %{}, now: @now) access_claims = valid_claims(%{ @@ -299,9 +298,12 @@ defmodule Smolquery.Auth.OIDC.TokenTest do }) assert {:ok, context} = - Token.verify(token(access_claims), config, @jwks, %{}, now: @now) + Token.verify(token(access_claims), @base_config, @jwks, %{}, now: @now) assert MapSet.equal?(context.capabilities, MapSet.new([:query])) + + unbound = %{@base_config | web_client_id: nil} + assert :error = Token.verify(token(valid_claims()), unbound, @jwks, %{}, now: @now) end test "enforces optional type and required claim values" do diff --git a/test/smolquery_api/oidc_auth_test.exs b/test/smolquery_api/oidc_auth_test.exs index 60e1f20b..07106890 100644 --- a/test/smolquery_api/oidc_auth_test.exs +++ b/test/smolquery_api/oidc_auth_test.exs @@ -50,6 +50,7 @@ defmodule SmolqueryApi.OIDCAuthTest do oidc: [ issuer: "https://issuer.example", api_audience: "smolquery-api", + web_client_id: "smolquery-web", claim_capabilities: %{ "scope" => %{ "query" => [:query], diff --git a/test/smolquery_api/runtime_test.exs b/test/smolquery_api/runtime_test.exs index 40cdfe78..385e018a 100644 --- a/test/smolquery_api/runtime_test.exs +++ b/test/smolquery_api/runtime_test.exs @@ -33,7 +33,8 @@ defmodule SmolqueryApi.RuntimeTest do auth_mode: :oidc, oidc: [ issuer: "https://issuer.example", - api_audience: "smolquery-api" + api_audience: "smolquery-api", + web_client_id: "smolquery-web" ] ) @@ -41,6 +42,28 @@ defmodule SmolqueryApi.RuntimeTest do assert runtime.context == nil assert runtime.oidc.issuer == "https://issuer.example" + assert_raise ArgumentError, ~r/distinguish access tokens from browser ID tokens/, fn -> + Runtime.new( + name: :api_runtime_test, + auth_mode: :oidc, + oidc: [issuer: "https://issuer.example", api_audience: "smolquery-api"] + ) + end + + profile_runtime = + Runtime.new( + name: :api_runtime_profile, + auth_mode: :oidc, + oidc: [ + issuer: "https://issuer.example", + api_audience: "smolquery-api", + api_typ_allowlist: ["at+jwt"] + ] + ) + + assert profile_runtime.oidc.web_client_id == nil + assert profile_runtime.oidc.typ_allowlist == ["at+jwt"] + assert_raise ArgumentError, ~r/invalid value/, fn -> Runtime.new(name: :api_runtime_test, auth_mode: :invalid, api_key: "k") end