diff --git a/config/runtime.exs b/config/runtime.exs index 2ab27f04..438a7a8e 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -69,9 +69,46 @@ 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 + +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}, + {"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_REFRESH_FAILURE_BACKOFF_MS", :refresh_failure_backoff_ms, 86_400_000}, {"SMOLQUERY_OIDC_CONNECT_TIMEOUT_MS", :connect_timeout_ms, 30_000}, {"SMOLQUERY_OIDC_RECEIVE_TIMEOUT_MS", :receive_timeout_ms, 60_000}, diff --git a/docs/api.md b/docs/api.md index 79b1c20f..1f81837e 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 f761a891..b0cdcfd6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -31,16 +31,16 @@ 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 | |---|---| | `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` | @@ -48,21 +48,47 @@ never opens either listener. | `SMOLQUERY_OIDC_WEB_SCOPES` | comma-separated browser authorization scopes (default `openid`); `openid` is mandatory, with at most 32 unique scope tokens and 1024 bytes total | | `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_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` | 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 | | `SMOLQUERY_OIDC_DISCOVERY_MAX_AGE_MS` / `SMOLQUERY_OIDC_JWKS_MAX_AGE_MS` | bounded cache freshness windows (defaults `3600000`) | +| `SMOLQUERY_OIDC_FORCED_REFRESH_COOLDOWN_MS` | positive minimum interval between unknown-`kid` forced JWKS fetches (default `1000`); concurrent/repeated attempts reuse the current cache and fail closed if the key remains unknown | | `SMOLQUERY_OIDC_REFRESH_FAILURE_BACKOFF_MS` | positive interval suppressing repeated discovery/JWKS network attempts after a failed refresh (default `1000`) | | `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 discovery client requires JSON responses, byte-for-byte issuer equality, HTTPS -authorization/token/JWKS endpoints, an algorithm overlap with the local +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. 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 +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, an algorithm overlap with the local asymmetric allowlist, unique key ids, and at least one public signing key whose explicit algorithm and key type are compatible with that allowlist. It refuses redirects and bounds response bodies. Refresh I/O runs outside the cache process, so fresh reads continue while a key fetch is in -flight; expired data still fails closed. A failed refresh suppresses repeated -network attempts for the configured backoff. The client does not trust `jku`, -token headers, claims, or provider groups as tenant identifiers. +flight; expired data still fails closed. Unknown-`kid` refreshes use a global +cooldown measured from fetch completion, and failed discovery/JWKS attempts +start a separate retry backoff. Protected `jku`, `jwk`, `x5u`, `crit`, and `b64` +headers are rejected. The client 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 6440f8a1..2a05ff8d 100644 --- a/lib/smolquery/auth/oidc/config.ex +++ b/lib/smolquery/auth/oidc/config.ex @@ -24,8 +24,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, :refresh_failure_backoff_ms, :connect_timeout_ms, :receive_timeout_ms, @@ -36,6 +42,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, @@ -48,8 +55,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: pos_integer(), refresh_failure_backoff_ms: pos_integer(), connect_timeout_ms: pos_integer(), receive_timeout_ms: pos_integer(), @@ -62,11 +75,17 @@ 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, refresh_failure_backoff_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, web_scopes: ["openid"], claim_capabilities: %{} @@ -87,6 +106,7 @@ defmodule Smolquery.Auth.OIDC.Config do web_client_id = web_client_id(config, role) + :ok = distinct_audiences(config) {web_origin, web_redirect_uri} = web_urls(config, role) web_scopes = web_scopes(config, role) auth_method = config |> Keyword.fetch!(:web_client_auth_method) |> auth_method!() @@ -94,6 +114,8 @@ defmodule Smolquery.Auth.OIDC.Config do algorithms = algorithms!(Keyword.fetch!(config, :algorithms)) claim_capabilities = claim_capabilities!(Keyword.fetch!(config, :claim_capabilities)) + {typ_allowlist, required_claims} = token_profile(config, role) + %__MODULE__{ issuer: issuer, api_audience: api_audience, @@ -106,6 +128,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, @@ -115,6 +160,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: + positive_bounded_integer!( + config, + :forced_refresh_cooldown_ms, + "SMOLQUERY_OIDC_FORCED_REFRESH_COOLDOWN_MS", + 86_400_000 + ), refresh_failure_backoff_ms: positive_bounded_integer!( config, @@ -186,6 +238,60 @@ 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: [] + + 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), 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!(env, claims, "a map of claim names to string lists") + end + end) + end + + 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), do: @@ -196,6 +302,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_client_id(config, :web), do: nonempty!(Keyword.get(config, :web_client_id), "SMOLQUERY_OIDC_WEB_CLIENT_ID") diff --git a/lib/smolquery/auth/oidc/discovery.ex b/lib/smolquery/auth/oidc/discovery.ex index 43b4bf93..b9492583 100644 --- a/lib/smolquery/auth/oidc/discovery.ex +++ b/lib/smolquery/auth/oidc/discovery.ex @@ -267,13 +267,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) @@ -319,9 +319,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 e341d42c..ac90dd72 100644 --- a/lib/smolquery/auth/oidc/provider.ex +++ b/lib/smolquery/auth/oidc/provider.ex @@ -30,6 +30,7 @@ defmodule Smolquery.Auth.OIDC.Provider do metadata_at: integer(), jwks: map(), jwks_at: integer(), + forced_refresh_at: integer() | nil, refresh: refresh() | nil, refresh_error: term() | nil, refresh_failed_at: integer() | nil, @@ -51,7 +52,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) @@ -71,6 +72,7 @@ defmodule Smolquery.Auth.OIDC.Provider do metadata_at: now, jwks: jwks, jwks_at: now, + forced_refresh_at: nil, refresh: nil, refresh_error: nil, refresh_failed_at: nil, @@ -104,9 +106,19 @@ defmodule Smolquery.Auth.OIDC.Provider do @impl GenServer def handle_call(:refresh_jwks, from, state) do - if fresh?(state.metadata_at, state.config.discovery_max_age_ms), - do: start_refresh(:jwks, :refresh_jwks, from, state), - else: start_refresh(:provider, :refresh_jwks, from, state) + cond do + not fresh?(state.metadata_at, state.config.discovery_max_age_ms) -> + start_refresh(:provider, :refresh_jwks, from, state) + + state.refresh != nil -> + {:reply, {:ok, state.jwks}, state} + + not forced_refresh_allowed?(state) -> + {:reply, {:ok, state.jwks}, state} + + true -> + start_refresh(:jwks, :refresh_jwks, from, state) + end end @impl GenServer @@ -126,15 +138,15 @@ defmodule Smolquery.Auth.OIDC.Provider do %{refresh: %{monitor_ref: monitor_ref} = refresh} = state ) do reason = :provider_refresh_failed + now = now_ms() GenServer.reply(refresh.from, {:error, reason}) - {:noreply, - %{ - state - | refresh: nil, - refresh_error: reason, - refresh_failed_at: now_ms() - }} + state = + state + |> mark_forced_refresh(refresh.operation, now) + |> Map.merge(%{refresh: nil, refresh_error: reason, refresh_failed_at: now}) + + {:noreply, state} end def handle_info(_message, state), do: {:noreply, state} @@ -200,20 +212,38 @@ defmodule Smolquery.Auth.OIDC.Provider do defp complete_refresh(refresh, {:ok, values}, state) do now = now_ms() - state = publish_refresh(refresh.kind, values, now, state) - {success_reply(refresh.operation, state), clear_failure(state)} + + state = + state + |> publish_refresh(refresh.kind, values, now) + |> mark_forced_refresh(refresh.operation, now) + |> clear_failure() + + {success_reply(refresh.operation, state), state} end - defp complete_refresh(_refresh, {:error, reason}, state) do - {{:error, reason}, %{state | refresh_error: reason, refresh_failed_at: now_ms()}} + defp complete_refresh(refresh, {:error, reason}, state) do + now = now_ms() + + state = + state + |> mark_forced_refresh(refresh.operation, now) + |> Map.merge(%{refresh_error: reason, refresh_failed_at: now}) + + {{:error, reason}, state} end - defp publish_refresh(:provider, %{metadata: metadata, jwks: jwks}, now, state), + defp publish_refresh(state, :provider, %{metadata: metadata, jwks: jwks}, now), do: %{state | metadata: metadata, metadata_at: now, jwks: jwks, jwks_at: now} - defp publish_refresh(:jwks, %{jwks: jwks}, now, state), + defp publish_refresh(state, :jwks, %{jwks: jwks}, now), do: %{state | jwks: jwks, jwks_at: now} + defp mark_forced_refresh(state, :refresh_jwks, now), + do: %{state | forced_refresh_at: now} + + defp mark_forced_refresh(state, _operation, _now), do: state + defp success_reply(:metadata, state), do: {:ok, state.metadata} defp success_reply(operation, state) when operation in [:jwks, :refresh_jwks], @@ -226,6 +256,11 @@ defmodule Smolquery.Auth.OIDC.Provider do defp retry_allowed?(state), do: now_ms() - state.refresh_failed_at >= state.config.refresh_failure_backoff_ms + 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..36dd8294 --- /dev/null +++ b/lib/smolquery/auth/oidc/token.ex @@ -0,0 +1,356 @@ +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 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), + {: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 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), + {: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 <- 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), + :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 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 -> + {: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 35be5438..165eb191 100644 --- a/lib/smolquery_api/runtime.ex +++ b/lib/smolquery_api/runtime.ex @@ -75,8 +75,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 @@ -103,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 31a53333..5010035f 100644 --- a/test/smolquery/auth/oidc/config_test.exs +++ b/test/smolquery/auth/oidc/config_test.exs @@ -154,6 +154,98 @@ 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, 0, "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 "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 "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") + + 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/auth/oidc/discovery_test.exs b/test/smolquery/auth/oidc/discovery_test.exs index 60f70456..65b89a77 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,36 +103,46 @@ defmodule Smolquery.Auth.OIDC.DiscoveryTest do end test "requires a signing key compatible with the configured algorithms" do - rsa = %{"kid" => "rsa", "kty" => "RSA", "n" => "AQ", "e" => "AQAB"} - ec = %{"kid" => "ec", "kty" => "EC", "crv" => "P-256", "x" => "AQ", "y" => "AQ"} - - assert :ok = Discovery.validate_jwks(%{"keys" => [rsa]}, @config) + assert :ok = Discovery.validate_jwks(%{"keys" => [@rsa_public]}, @config) assert {:error, :jwks_no_compatible_signing_key} = - Discovery.validate_jwks(%{"keys" => [ec]}, @config) + Discovery.validate_jwks(%{"keys" => [@ec_public]}, @config) assert {:error, :jwks_no_compatible_signing_key} = - Discovery.validate_jwks(%{"keys" => [Map.put(rsa, "use", "enc")]}, @config) + Discovery.validate_jwks(%{"keys" => [Map.put(@rsa_public, "use", "enc")]}, @config) mixed_config = %{@config | algorithms: ["RS256", "ES256"]} assert {:error, :jwks_no_compatible_signing_key} = - Discovery.validate_jwks(%{"keys" => [Map.put(rsa, "alg", "ES256")]}, mixed_config) + Discovery.validate_jwks( + %{"keys" => [Map.put(@rsa_public, "alg", "ES256")]}, + mixed_config + ) assert {:error, :jwks_duplicate_kid} = - Discovery.validate_jwks(%{"keys" => [rsa, rsa]}, @config) + Discovery.validate_jwks(%{"keys" => [@rsa_public, @rsa_public]}, @config) 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" + } ] }) @@ -132,18 +152,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 7609aa16..809ca332 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() @@ -39,7 +50,13 @@ defmodule Smolquery.Auth.OIDC.ProviderTest do ec_only = %{ "keys" => [ - %{"kid" => "ec", "kty" => "EC", "crv" => "P-256", "x" => "AQ", "y" => "AQ"} + %{ + "kid" => "ec", + "kty" => "EC", + "crv" => "P-256", + "x" => Base.url_encode64(:binary.copy(<<1>>, 32), padding: false), + "y" => Base.url_encode64(:binary.copy(<<2>>, 32), padding: false) + } ] } @@ -91,7 +108,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 -> @@ -124,7 +141,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 -> @@ -151,6 +168,80 @@ defmodule Smolquery.Auth.OIDC.ProviderTest do Process.exit(pid, :normal) end + 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 "fresh cache reads remain responsive while a forced refresh is in flight" do test = self() {:ok, counter} = Agent.start_link(fn -> 0 end) @@ -239,6 +330,7 @@ defmodule Smolquery.Auth.OIDC.ProviderTest do {:ok, response(@jwks)} {"https://keys.example/keys", _} -> + Process.sleep(20) {:error, :timeout} end end @@ -249,7 +341,9 @@ defmodule Smolquery.Auth.OIDC.ProviderTest do oidc: [ issuer: "https://issuer.example/", api_audience: "api", - jwks_max_age_ms: 0 + jwks_max_age_ms: 0, + forced_refresh_cooldown_ms: 1, + refresh_failure_backoff_ms: 100_000 ] ], :api diff --git a/test/smolquery/auth/oidc/token_test.exs b/test/smolquery/auth/oidc/token_test.exs new file mode 100644 index 00000000..f8b3a27c --- /dev/null +++ b/test/smolquery/auth/oidc/token_test.exs @@ -0,0 +1,403 @@ +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", + web_client_id: "smolquery-web", + 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 "rejects browser ID-token audiences at the API boundary" do + 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), @base_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), @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 + 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 ab26042a..b1488d5e 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 @@ -139,7 +144,17 @@ defmodule Smolquery.RuntimeConfigTest do "SMOLQUERY_OIDC_CLOCK_SKEW" => "30", "SMOLQUERY_OIDC_REFRESH_FAILURE_BACKOFF_MS" => "250", "SMOLQUERY_OIDC_CLAIM_CAPABILITIES" => - ~s({"roles":{"reader":["query"],"operator":["web_access"]}}) + ~s({"roles":{"reader":["query"],"operator":["web_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", + "SMOLQUERY_OIDC_FORCED_REFRESH_COOLDOWN_MS" => "250" }, fn -> runtime = Config.Reader.read!("config/runtime.exs", env: :prod, target: :host) @@ -147,6 +162,16 @@ defmodule Smolquery.RuntimeConfigTest do assert oidc[:issuer] == "https://issuer.example/" assert oidc[:algorithms] == ["RS256", "PS256"] + 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 + assert oidc[:forced_refresh_cooldown_ms] == 250 assert oidc[:refresh_failure_backoff_ms] == 250 assert oidc[:claim_capabilities] == %{ diff --git a/test/smolquery_api/oidc_auth_test.exs b/test/smolquery_api/oidc_auth_test.exs new file mode 100644 index 00000000..07106890 --- /dev/null +++ b/test/smolquery_api/oidc_auth_test.exs @@ -0,0 +1,118 @@ +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", + web_client_id: "smolquery-web", + 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 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