Skip to content
37 changes: 37 additions & 0 deletions config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
21 changes: 15 additions & 6 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
48 changes: 37 additions & 11 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,38 +31,64 @@ 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` |
| `SMOLQUERY_OIDC_WEB_REDIRECT_URI` | authorization callback URI; must be exactly the web origin plus `/auth/callback`, without a query |
| `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 |

Expand Down
161 changes: 161 additions & 0 deletions lib/smolquery/auth/oidc/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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(),
Expand All @@ -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: %{}
Expand All @@ -87,13 +106,16 @@ 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!()
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, required_claims} = token_profile(config, role)

%__MODULE__{
issuer: issuer,
api_audience: api_audience,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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")

Expand Down
Loading
Loading