Identity providers
haltsql-server accepts OIDC access tokens (JWTs) from any provider that
publishes an OpenID Connect discovery document and a JWKS. The token decides
which policy the caller gets and which tenant the verdict is bound to, so an
agent cannot widen its own access by changing the request. This page gives a
setup recipe per provider; the full rules are in
authentication.md.
Providers covered:
- Generic OIDC (the building blocks every recipe uses)
- Keycloak
- Microsoft Entra ID
- Okta
- Auth0
- Testing with a real token
What is tested. HaltSQL's OIDC support is generic: nothing in it is specific to one vendor.
| Provider | How it is tested |
|---|---|
| Keycloak 26.4.2 | End to end against a real Keycloak: discovery, JWKS fetch, real tokens from the password and client-credentials grants, groups mapped to policies, the tenant from a token claim, and the allowed queries run in PostgreSQL (test/e2e/test_identity.py, test/auth/test_keycloak.ml). |
| Microsoft Entra ID, Okta, Auth0 | Their token shapes (groups claims, Entra ID overage markers, Auth0 namespaced claims, Entra object ids) are covered by unit tests (test/auth/test_groups.ml). They are not tested against the live services. |
| Any other OIDC provider | Generic OIDC. It works if the provider issues signed JWT access tokens with the checks below. |
Provider console steps on this page are kept high-level, because consoles change; follow the provider's own documentation for them. The HaltSQL configuration is exact.
Generic OIDC
Every recipe is a server.auth.oidc entry:
server:
auth:
oidc:
- name: corp-idp # appears in logs and audit records as oidc:corp-idp
issuer: https://idp.corp.example/ # must equal the token's iss exactly
audiences: [haltsql] # the token's aud must contain one of these
algorithms: [RS256] # default [RS256, ES256]
# jwks_uri: discovered from <issuer>/.well-known/openid-configuration
policy_from_groups: # or: policy, or policy_claim + allowed_policies
claim: groups
map:
- { group: haltsql-support, policy: support-bot }
- { group: haltsql-analysts, policy: saas-analytics }
context_claims:
tenant_id: tenant # context field <- claim path
What you need from the provider:
| Setting | Where it comes from |
|---|---|
issuer | The issuer value of the provider's discovery document, character for character (including any trailing /). HaltSQL fetches <issuer>/.well-known/openid-configuration and refuses a document whose issuer differs. |
audiences | The audience your provider puts in aud for tokens meant for HaltSQL (an API identifier, a client id, or a custom audience). |
jwks_uri | Normally discovered. Set it only when discovery is not available; it must be https and must not point to a loopback host unless the issuer does. |
algorithms | The signing algorithm of the provider's keys. HMAC (HS*) and none are always rejected, so a provider must sign with an asymmetric key (RS256, RS384, RS512, PS256, ES256, ES384 or EdDSA). |
Choosing the policy. Exactly one of:
policy: NAME: every token from this provider gets one policy. Use it when the provider entry serves one agent; pin the agent withrequired_claims(for example its client id).policy_claim+allowed_policies: a string claim names the policy, and only policies in the allowlist can be selected.policy_from_groups: the firstmapentry whosegroupthe token has wins, in the order written, so put the most privileged groups first. Groups are compared exactly (case-sensitive). Withoutdefault, a token that matches no entry is rejected (no_group_policy), and so is a token without the claim (missing_claim). See Policies from IdP groups.
Tenant from claims. context_claims maps a context field (the one your
policy reads as ${ctx.tenant_id}) to a claim. The path is tried first as
one claim name, so https://corp.example/tenant works, then as a dot path
through nested objects (org.id). The claim must exist, and must be a
string, an integral number, or a non-empty array of those; otherwise the
token is rejected. A request body cannot override these fields: a body field
not listed in request_context_fields is refused with
403 CONTEXT_NOT_ALLOWED.
Restricting tokens further. required_claims checks claims by value: a
string must equal the claim, or be an element of an array claim, or a word
of a space-separated claim such as scope; a list requires every value.
Key rotation and outages. Keys are cached for jwks_cache_seconds
(default 300). A token with an unknown kid triggers at most one refresh
every 30 seconds, so key rotation needs no restart. If the provider is
unreachable, cached keys stay usable for at most
min(jwks_cache_seconds, 3600) seconds past their expiry; after that, its
tokens get 503 AUTH_UNAVAILABLE until it is reachable again. See
Key fetching and caching.
Network. The server needs outbound HTTPS to the issuer's discovery document and JWKS. Responses are limited to 1 MiB and 5 seconds, and redirects are not followed.
Keycloak
Tested end to end with Keycloak 26.4.2.
In Keycloak (see the Keycloak Server Administration Guide for the exact screens):
- In the realm, create a client for the agent (for example
haltsql-agent). For an agent that runs as itself, enable client authentication and service accounts (client-credentials grant). - Add an Audience mapper to the client with the included custom
audience
haltsql, added to the access token. Without it, Keycloak's access tokens do not carry the audience HaltSQL expects. - Add a Group Membership mapper with token claim name
groups, Full group path off (so groups aresupport, not/support), added to the access token. - For the tenant, add a User Attribute mapper from the user attribute
tenantto the claimhaltsql.tenant, type String, added to the access token. Keycloak turns the dot into a nested object:{"haltsql": {"tenant": "acme"}}. - Put users (or the client's service-account user) into the groups.
HaltSQL config. This is the configuration the end-to-end test runs, with
https instead of the test's local http issuer:
server:
auth:
oidc:
- name: keycloak
issuer: https://sso.corp.example/realms/corp
audiences: [haltsql]
algorithms: [RS256]
policy_from_groups:
claim: groups
map:
- { group: support, policy: support-bot }
- { group: finance, policy: finance-analyst }
context_claims: { tenant_id: haltsql.tenant }
The issuer is https://<host>/realms/<realm> (older Keycloak versions add
/auth before /realms; copy the value from the realm's discovery
document).
Roles instead of groups. Realm roles are in realm_access.roles and
client roles in resource_access.<client>.roles, both in the access token
by default. Use either as the claim:
policy_from_groups:
claim: realm_access.roles
map:
- { group: haltsql-support, policy: support-bot }
Pitfalls.
- No groups, no claim. Keycloak leaves
groupsout for a user in no group. HaltSQL rejects that token withmissing_claim(unless you set adefault), which is the safe result, but it looks like a configuration error when you test with a fresh user. - A user without the tenant attribute gets a token without
haltsql.tenant; the token is rejected, so the user cannot query unscoped. allow_insecure_http: trueis only for local tests against a Keycloak in dev mode. Do not use it in production.
Microsoft Entra ID
Generic OIDC; token shapes are unit tested. A complete worked example, with
the az commands for the app registration and app roles, is in
Entra ID as the OIDC issuer.
In Entra ID:
- Register an application for HaltSQL (the API agents call) and set its
access token version to 2 (
api.requestedAccessTokenVersion = 2in the manifest). - Define app roles for the kinds of caller (for example
SQL.Verify, or one role per policy), and assign them to the agents' identities or to users and groups. - Optionally, require assignment so that only identities with a role can get a token.
HaltSQL config: one policy per app role. App roles arrive in the roles
claim, are scoped to the application and never overflow:
server:
auth:
oidc:
- name: entra
issuer: https://login.microsoftonline.com/<TENANT_ID>/v2.0
audiences: [<HALTSQL_APP_CLIENT_ID>]
algorithms: [RS256]
policy_from_groups:
claim: roles
map:
- { group: HaltSQL.Support, policy: support-bot }
- { group: HaltSQL.Analyst, policy: saas-analytics }
HaltSQL config: one pinned agent identity. For an agent that calls with
its managed identity or service principal, pin the identity with azp:
- name: entra-analytics-agent
issuer: https://login.microsoftonline.com/<TENANT_ID>/v2.0
audiences: [<HALTSQL_APP_CLIENT_ID>]
algorithms: [RS256]
policy: analytics-agent
required_claims:
azp: <AGENT_CLIENT_ID>
roles: SQL.Verify
request_context_fields: [tenant_id]
Several entries can share an issuer; HaltSQL tries them in order and uses the first one that verifies the token.
Groups and overage. You can map security groups instead of app roles
with claim: groups. The values are group object ids, not names:
policy_from_groups:
claim: groups
map:
- { group: 8b0e5a9c-1c1f-4c55-9d2a-5f1f3c0f2a11, policy: admin-readonly }
When a user is in more groups than fit in the token (200 for JWTs), Entra ID
leaves groups out and adds _claim_names/_claim_sources pointing at
Microsoft Graph (or hasgroups: true for implicit-flow tokens). HaltSQL
never calls Graph, because a partial group list could select the wrong
policy: it rejects such a token with groups_overage, even if a groups
claim is present too. To avoid overage, set groupMembershipClaims to
ApplicationGroup and assign only the relevant groups to the enterprise
application, or use app roles.
Pitfalls.
- v1.0 tokens. Without access token version 2, Entra ID issues tokens
whose
issishttps://sts.windows.net/<tenant>/, and they are rejected becauseissmust equalissuerexactly. If you must accept v1.0 tokens, configure that issuer in a separate entry, with the App ID URI as the audience andjwks_uri: https://login.microsoftonline.com/<tenant>/discovery/keys; do not mix the two. tidis not your tenant. Thetidclaim is the Entra directory, not the customer tenant your tables are keyed by. App-only tokens carry no customer tenant at all: listtenant_idinrequest_context_fields, or use one identity per tenant.policy_claimdoes not work withroles.policy_claimneeds a string claim androlesis an array; usepolicy_from_groupswithclaim: roles.
Okta
Generic OIDC; token shapes are unit tested.
In Okta:
- Use a custom authorization server (for example the one named
default). Its issuer looks likehttps://corp.okta.com/oauth2/default; its Audience setting is theaudof its access tokens. - Add a
groupsclaim to the access token, with a filter (for example "starts withhaltsql-") so the claim stays small. - For the tenant, add a custom claim from a user profile attribute (for
example
tenantfromuser.tenant).
HaltSQL config:
server:
auth:
oidc:
- name: corp-okta
issuer: https://corp.okta.com/oauth2/default
audiences: [api://haltsql]
algorithms: [RS256]
policy_from_groups:
claim: groups
map:
- { group: haltsql-admins, policy: admin-readonly }
- { group: haltsql-support, policy: support-bot }
context_claims: { tenant_id: tenant }
required_claims: { scp: sql.verify }
required_claims: {scp: sql.verify} only accepts tokens that were granted
that scope; scp is an array in Okta access tokens, and the value must be
one of its elements.
Machine-to-machine tokens. Client-credentials tokens describe the
application, not a user, so they carry no user groups. Give each agent
application its own provider entry with a pinned policy and
required_claims on its client id (Okta access tokens carry it in cid),
for example required_claims: {cid: <AGENT_CLIENT_ID>}.
Pitfalls.
- Use the custom authorization server's issuer, exactly as its discovery
document reports it. Tokens from a different authorization server have a
different
issand are not verified by this entry. - An unfiltered groups claim can be large; HaltSQL rejects tokens over 16 KiB as malformed.
Auth0
Generic OIDC; token shapes are unit tested.
In Auth0:
-
Create an API for HaltSQL. Its Identifier is the audience. Use the RS256 signing algorithm: HS256 tokens are always rejected.
-
Add custom claims from an Action. Auth0 requires namespaced names for custom claims, for example
https://corp.example/groupsandhttps://corp.example/tenant. In a post-login Action:exports.onExecutePostLogin = async (event, api) => {api.accessToken.setCustomClaim('https://corp.example/groups', event.authorization?.roles ?? []);api.accessToken.setCustomClaim('https://corp.example/tenant', event.user.app_metadata.tenant);};
HaltSQL config:
server:
auth:
oidc:
- name: auth0
issuer: https://corp.eu.auth0.com/ # Auth0 issuers end in "/"
audiences: [https://haltsql.corp.example]
algorithms: [RS256]
policy_from_groups:
claim: https://corp.example/groups
map:
- { group: admins, policy: admin-readonly }
- { group: support, policy: support-bot }
context_claims: { tenant_id: https://corp.example/tenant }
Namespaced claim names contain dots and slashes. HaltSQL looks a path up as
one claim name first, so https://corp.example/groups is read as written,
not as a dot path.
Pitfalls.
- The trailing slash. Auth0's
issends in/. Configureissuerwith the slash, or every token failswrong_issuer. (HaltSQL removes the slash only to build the discovery URL.) - Missing tenant metadata. If a user has no tenant, the Action sets an empty claim or none, and the token is rejected. That is the safe result; fix the user's metadata.
- Machine-to-machine tokens do not run post-login Actions. Use a pinned
policyper application, or set the claims in a credentials-exchange Action.
Testing with a real token
Do this once per provider before agents depend on it.
-
Get a token the way the agent will. For a Keycloak client with a service account:
TOKEN=$(curl -s https://sso.corp.example/realms/corp/protocol/openid-connect/token \-d grant_type=client_credentials -d client_id=haltsql-agent \-d client_secret="$CLIENT_SECRET" | jq -r .access_token)Other providers have an equivalent token endpoint; see their docs.
-
Read its claims and compare them with your config (
iss,aud,exp, the groups claim, the tenant claim):python3 -c 'import base64,json,sys; p=sys.argv[1].split(".")[1]; print(json.dumps(json.loads(base64.urlsafe_b64decode(p+"="*(-len(p)%4))),indent=2))' "$TOKEN" -
Ask HaltSQL which policy the token selects.
GET /v1/policiesreturns only the caller's pinned policy:curl -s -H "Authorization: Bearer $TOKEN" https://haltsql.internal:8443/v1/policies# {"policies":[{"name":"support-bot","version":"sha256:...","mode":"enforce"}]} -
Verify a query and check that
bind_paramscarries the tenant from the token:curl -s -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \https://haltsql.internal:8443/v1/verify \-d '{"sql":"SELECT id, subject FROM app.tickets WHERE tenant_id = $1 LIMIT 10"}' -
Check the negative cases. A body
contextwith another tenant must get403 CONTEXT_NOT_ALLOWED; a token from a user in no mapped group must get401.
When a token is rejected, the response says why, and the server logs
oidc token rejected at info with a stable reason field: expired,
wrong_issuer, wrong_audience, bad_signature, unknown_kid,
alg_not_allowed, missing_claim, claim_mismatch, bad_claim,
groups_overage, no_group_policy and others. JWKS fetch failures are
logged at warn as oidc signing keys unavailable. The metric
haltsql_auth_total{method="oidc",result="ok|rejected|unavailable"} counts
the outcomes. Audit records of OIDC callers have caller: "oidc:<name>",
caller_type: "oidc" and the token's sub as subject; the token itself
is never logged.
Related
- Authentication and TLS: the request pipeline, all checks, mTLS and static tokens.
- Config reference: server.auth.oidc and policy_from_groups.
- Tenancy: how
${ctx.*}values bind tenant predicates. - HTTP API:
/v1/verify,/v1/policiesand error codes. - Deploying on Azure.