diff --git a/documentation/changelog.mdx b/documentation/changelog.mdx
index dd8832ec9..4502d0dc1 100644
--- a/documentation/changelog.mdx
+++ b/documentation/changelog.mdx
@@ -42,6 +42,8 @@ This page tracks significant updates to the QuestDB documentation.
- [ALTER TABLE SET FORMAT](/docs/query/sql/alter-table-set-format/) - New reference page for switching a table's partition storage format between `NATIVE` and `PARQUET`
- [QWP configuration](/docs/configuration/qwp/) - Server-side settings for the QWP ingestion (`/write/v4`) and query (`/read/v1`) endpoints
- [Check timestamp order](/docs/cookbook/sql/time-series/check-timestamp-order/) and [Check column sort order](/docs/cookbook/sql/advanced/check-column-sort-order/) - Two cookbook recipes that detect unsorted data with `lag()`: whether a table, CSV import or external Parquet file is ordered by its timestamp, and whether one column is sorted with respect to another
+- [OIDC Device Authorization Flow](/docs/security/oidc-device-flow/) - Interactive OIDC sign-in for headless and remote applications, including configuration, token lifecycle, a protocol walkthrough, and examples for the Java, Python, Rust, C, and C++ clients
+- [OIDC settings endpoint](/docs/security/oidc/#settings-endpoint) - How a client reads QuestDB's OIDC configuration from `/settings` to discover the authorization, token, and device authorization endpoints, and whether PKCE and the `state` parameter are required
- [Kubernetes Operator](/docs/enterprise-kubernetes-operator/) - New manual for running QuestDB Enterprise clusters on Kubernetes, covering [installation](/docs/enterprise-kubernetes-operator/installation/), getting started on [AWS](/docs/enterprise-kubernetes-operator/getting-started/aws/) and [Azure](/docs/enterprise-kubernetes-operator/getting-started/azure/), [configuration](/docs/enterprise-kubernetes-operator/configuration/), [day-to-day operations](/docs/enterprise-kubernetes-operator/operations/operator/), [high availability](/docs/enterprise-kubernetes-operator/high-availability/), [known limitations](/docs/enterprise-kubernetes-operator/known-limitations/), and the full [API reference](/docs/enterprise-kubernetes-operator/reference/api/)
- [ALTER TABLE SUSPEND WAL](/docs/query/sql/alter-table-suspend-wal/) - New reference page for deliberately stopping the WAL apply job, covering the quiescent-table use case that `REBASE WAL` requires, and the trap that writes to a suspended table succeed while staying invisible to queries until `RESUME WAL`
@@ -73,6 +75,7 @@ This page tracks significant updates to the QuestDB documentation.
- Added the [`cairo.sql.parquet.cache.memory.size`](/docs/configuration/cairo-engine/) configuration property (256 MB default), deprecating the slot-based `cairo.sql.parquet.frame.cache.capacity`
- Documented [`cairo.root`](/docs/configuration/cairo-engine/) absolute-path behavior: the `conf`, `import`, `export`, `tmp`, and `.checkpoint` directories become siblings of the specified directory rather than children of the server root, so leave it at the default under Docker
- [read_parquet](/docs/query/functions/parquet/#designated-timestamp) - Documented nominating a designated timestamp on a Parquet file with `TIMESTAMP()` (applied directly, on a sub-query, or on a CTE), and importing a file into a table with `INSERT INTO ... SELECT` or `CREATE TABLE AS`
+- Documented the OIDC configuration options that were missing from the [OIDC configuration page](/docs/configuration/oidc/): `acl.oidc.state.required`, `acl.oidc.public.keys.expiry`, `acl.oidc.response.buffer.size`, `acl.oidc.string.pool.capacity`, and `acl.oidc.device.authorization.endpoint`; also corrected `acl.oidc.pkce.enabled` to its real name `acl.oidc.pkce.required`, and documented `acl.oidc.groups.claim` as mandatory with no default
- [SAMPLE BY](/docs/query/sql/sample-by/#sample-units) - Documented that the count is optional and defaults to `1`, so `SAMPLE BY h` is the same as `SAMPLE BY 1h`, and that it must be a positive integer written with no space before the unit
- [REST API](/docs/connect/compatibility/rest-api/#execute) - Documented `/execute` and `/validate`, which compiles a query and returns its column metadata without running it. `/exec` is now marked deprecated in favour of `/execute`
- [HTTP server configuration](/docs/configuration/http-server/#circuitbreakerbuffersize) - Flagged `circuit.breaker.buffer.size` as unsupported since QuestDB 10.0.0; a config that still sets it logs an advisory, or fails to start under `config.validation.strict`
@@ -87,6 +90,11 @@ This page tracks significant updates to the QuestDB documentation.
### Updated
+- [OpenID Connect (OIDC)](/docs/security/oidc/#1-secret-generation) - Noted that some Identity Providers require the `state` parameter in the authorization request, and that `acl.oidc.state.required` should be set to `true` for those providers
+- [Which token to send](/docs/security/oidc/#which-token-to-send) - Documented that `acl.oidc.groups.encoded.in.token` decides whether a client sends the access token or the ID token, and that sending the wrong one fails authentication
+- [`acl.oidc.groups.encoded.in.token`](/docs/configuration/oidc/#acloidcgroupsencodedintoken) - Documented what QuestDB validates when it checks a token itself: the signature, the audience and the presence of the claims, but not `exp`, `nbf` or `iss`, so an expired token is still accepted
+- Scoped three [OIDC options](/docs/configuration/oidc/) to what the server actually does: `acl.oidc.audience` is only checked when the groups are encoded in the token, the mandatory `openid` in `acl.oidc.scope` is enforced by the provider rather than at startup, and `acl.oidc.ropc.flow.enabled` makes QuestDB itself exchange HTTP basic and PGWire credentials for a token at the provider
+- [PingFederate SSO](/docs/security/oidc/#questdb-configuration-for-pingfederate) - Added the missing QuestDB `server.conf` block to the walkthrough, which relied on defaults for settings that have none
- [Replication setup guide](/docs/high-availability/setup/#migration-procedures) - Planned primary migration now points at the in-place switch; the restart-based flow is kept for older versions and the emergency migration is marked as the lossy path
- [Error codes](/docs/troubleshooting/error-codes/#er005) - ER005 now covers the refusal of an in-place promotion, and ER006 the restart of a demoted node with a stale `replication.role`
- Client libraries rewritten for the QWP binary protocol, unifying ingestion and streaming SQL queries under one handle: [Java](/docs/connect/clients/java/), [Python](/docs/connect/clients/python/), [Go](/docs/connect/clients/go/), [C & C++](/docs/connect/clients/c-and-cpp/), [Rust](/docs/connect/clients/rust/), and [.NET](/docs/connect/clients/dotnet/)
diff --git a/documentation/configuration/http-server.md b/documentation/configuration/http-server.md
index cb8971ee4..76e968d7a 100644
--- a/documentation/configuration/http-server.md
+++ b/documentation/configuration/http-server.md
@@ -515,11 +515,25 @@ Context path for the file import service.
### http.context.settings
-- **Default**: `/settings`
+- **Default**: `/settings`, relative to
+ [`http.context.web.console`](#httpcontextwebconsole)
- **Reloadable**: no
-Context path for the service which provides server-side settings to the Web
-Console.
+Context path for the service that serves server-side settings to clients, such
+as an OIDC client discovering the provider's endpoints from the
+[settings endpoint](/docs/security/oidc/#settings-endpoint).
+
+Accepts a comma-separated list of paths. Setting it adds paths rather than
+moving the service: QuestDB keeps serving the default path as well, so the
+[Web Console](/docs/getting-started/web-console/overview/), which does not read
+this setting, keeps working.
+
+The default path follows
+[`http.context.web.console`](#httpcontextwebconsole), so it is `/settings` only
+while that setting is at its default. Setting
+`http.context.web.console=/console` makes the preserved path
+`/console/settings`, and bare `/settings` is then served only if it is listed
+here explicitly.
### http.context.table.status
diff --git a/documentation/configuration/iam.md b/documentation/configuration/iam.md
index f3ecdec57..de26f9151 100644
--- a/documentation/configuration/iam.md
+++ b/documentation/configuration/iam.md
@@ -47,6 +47,10 @@ Enables or disables the built-in admin user.
When enabled, the browser's basic auth popup window is used instead of the
Web Console's login screen. Present for backwards compatibility only.
+Cannot be enabled together with
+[`acl.oidc.enabled`](/docs/configuration/oidc/#acloidcenabled). Setting both
+to `true` fails server startup.
+
### acl.enabled
- **Default**: `true`
diff --git a/documentation/configuration/oidc.md b/documentation/configuration/oidc.md
index e0c5932bd..20820b5c1 100644
--- a/documentation/configuration/oidc.md
+++ b/documentation/configuration/oidc.md
@@ -1,6 +1,7 @@
---
title: OpenID Connect (OIDC)
-description: Configuration settings for OpenID Connect integration in QuestDB Enterprise.
+sidebar_label: OIDC settings
+description: "QuestDB Enterprise acl.oidc.* settings reference: minimum configuration and startup rules, endpoints, TLS, user and group claims, caching and buffers."
---
:::note
@@ -16,6 +17,46 @@ Provider (IdP).
For detailed information about OIDC, see the
[OpenID Connect (OIDC) integration guide](/docs/security/oidc).
+## Minimum configuration
+
+OIDC requires [`acl.enabled`](/docs/configuration/iam/#aclenabled) to be `true`,
+which is the default. With access control disabled no OIDC authentication takes
+place, but the OIDC settings are still validated at startup: with
+`acl.oidc.enabled=true` the server enforces every rule below and downloads the
+provider's configuration document, so an inconsistent OIDC configuration still
+prevents it from starting. Set `acl.oidc.enabled=false` to stop the rules below being
+enforced. `acl.oidc.configuration.url` is still parsed even then, and its
+scheme must still match [`acl.oidc.tls.enabled`](#acloidctlsenabled), so a
+malformed value there fails startup with OIDC switched off.
+
+A working setup against a Ping Identity provider needs four settings. Every
+other setting has a usable default:
+
+```ini title="server.conf"
+acl.oidc.enabled=true
+acl.oidc.host=oidc.provider
+acl.oidc.client.id=questdb
+acl.oidc.groups.claim=groups
+```
+
+QuestDB refuses to start when the OIDC configuration is inconsistent. With
+`acl.oidc.enabled=true`:
+
+- [`acl.oidc.client.id`](#acloidcclientid) and
+ [`acl.oidc.groups.claim`](#acloidcgroupsclaim) must be set.
+- Exactly one of [`acl.oidc.host`](#acloidchost) and
+ [`acl.oidc.configuration.url`](#acloidcconfigurationurl) must be set.
+- [`acl.basic.auth.realm.enabled`](/docs/configuration/iam/#aclbasicauthrealmenabled)
+ must be `false`.
+- [`acl.oidc.tls.keystore.path`](#acloidctlskeystorepath) and
+ [`acl.oidc.tls.keystore.password`](#acloidctlskeystorepassword) must both be
+ set, or neither.
+- [`acl.oidc.tls.enabled`](#acloidctlsenabled) must match the scheme of every
+ OIDC Provider URL.
+- When [`acl.oidc.configuration.url`](#acloidcconfigurationurl) is set, the
+ document must be downloadable and parseable, and must name the authorization,
+ token, user info and JWKS endpoints.
+
## General
### acl.oidc.audience
@@ -26,6 +67,12 @@ For detailed information about OIDC, see the
OAuth2 audience as set on the tokens issued by the OIDC Provider. Defaults
to the client ID if not set.
+Only used when
+[`acl.oidc.groups.encoded.in.token`](#acloidcgroupsencodedintoken) is `true`,
+which is the only case in which QuestDB validates tokens itself. In the default
+user info flow the OIDC Provider decides whether the token is valid, and
+QuestDB does not check the audience at all.
+
### acl.oidc.client.id
- **Default**: none
@@ -42,35 +89,53 @@ enabled.
URL where the OpenID Provider's configuration information can be loaded in
JSON format. Should always end with `/.well-known/openid-configuration`.
+QuestDB downloads the document at startup and takes every endpoint from it,
+so the settings under [Endpoints](#endpoints) and `acl.oidc.port` are not
+used. The server does not start if the document cannot be downloaded or
+parsed, or if it is missing the authorization, token, user info or JWKS
+endpoint.
+
+Mutually exclusive with `acl.oidc.host`: setting both fails server startup.
+
### acl.oidc.enabled
- **Default**: `false`
- **Reloadable**: no
-Enables or disables OIDC authentication. When enabled, several other
-configuration options must also be set.
+Enables or disables OIDC authentication. When enabled, `acl.oidc.client.id`
+and `acl.oidc.groups.claim` must also be set, along with either
+`acl.oidc.host` or `acl.oidc.configuration.url`. See
+[Minimum configuration](#minimum-configuration) for the full set of startup
+requirements.
+
+OIDC cannot be enabled together with
+[`acl.basic.auth.realm.enabled`](/docs/configuration/iam/#aclbasicauthrealmenabled).
+Setting both to `true` fails server startup.
### acl.oidc.host
- **Default**: none
- **Reloadable**: no
-OIDC provider hostname. Required when OIDC is enabled, unless the OIDC
-configuration URL is set.
+OIDC provider hostname. Required when OIDC is enabled, unless
+`acl.oidc.configuration.url` is set. The two are mutually exclusive: setting
+both fails server startup.
### acl.oidc.http.timeout
- **Default**: `30000`
- **Reloadable**: no
-OIDC provider HTTP request timeout in milliseconds.
+OIDC provider HTTP request timeout in milliseconds. Accepts a plain integer
+only.
### acl.oidc.port
- **Default**: `443`
- **Reloadable**: no
-OIDC provider port number.
+OIDC provider port number. Not used when `acl.oidc.configuration.url` is
+set, because the port is taken from the discovered endpoint URLs.
### acl.oidc.redirect.uri
@@ -79,7 +144,8 @@ OIDC provider port number.
The redirect URI tells the OIDC server where to redirect the user after
successful authentication. If not set, the Web Console defaults it to the
-location where it was loaded from (`window.location.href`).
+location it was loaded from, without the query string or fragment
+(`window.location.origin + window.location.pathname`).
### acl.oidc.scope
@@ -87,10 +153,30 @@ location where it was loaded from (`window.location.href`).
- **Reloadable**: no
The OIDC server asks consent for the scopes listed in this property. The
-scope `openid` is mandatory and must always be included.
+scope `openid` is mandatory and must always be included. That is an OIDC
+protocol requirement enforced by the provider, not a QuestDB startup check:
+QuestDB passes the value on without inspecting it, so leaving `openid` out
+fails at the provider rather than at startup.
+
+QuestDB uses the scopes in the requests it makes itself, in the
+[ROPC flow](#acloidcropcflowenabled), and publishes them on the
+[settings endpoint](/docs/security/oidc/#settings-endpoint) for clients which
+run the flow themselves.
+
+For the [Device Authorization Flow](/docs/security/oidc-device-flow/),
+add `offline_access` when the provider requires that scope before issuing a
+refresh token. Without a refresh token, a client must ask the user to sign in
+again after the current token expires.
## Authentication flows
+QuestDB publishes [`acl.oidc.pkce.required`](#acloidcpkcerequired) and
+[`acl.oidc.state.required`](#acloidcstaterequired) to clients through the
+[settings endpoint](/docs/security/oidc/#settings-endpoint), and enforces
+neither. The client generates the code verifier and the `state` value; the
+provider checks the verifier, and the client checks the `state` value it gets
+back.
+
### acl.oidc.pg.token.as.password.enabled
- **Default**: `false`
@@ -100,13 +186,14 @@ When enabled, the PGWire endpoint supports OIDC authentication. The OAuth2
token should be sent in the password field, while the username field should
contain the string `_sso`, or left empty if that is an option.
-### acl.oidc.pkce.enabled
+### acl.oidc.pkce.required
- **Default**: `true`
- **Reloadable**: no
-Enables or disables PKCE for the Authorization Code Flow. This should always
-be enabled in production. The Web Console is not fully secure without it.
+Tells clients that PKCE is required for the Authorization Code Flow. This
+should always be enabled in production. The Web Console is not fully secure
+without it.
### acl.oidc.ropc.flow.enabled
@@ -116,8 +203,42 @@ be enabled in production. The Web Console is not fully secure without it.
Enables or disables the Resource Owner Password Credentials flow. When
enabled, this flow must also be configured in the OIDC Provider.
+With it enabled QuestDB runs the flow itself: a username and password arriving
+over HTTP basic authentication or PGWire that match no local user are sent on to
+the OIDC Provider's token endpoint as a password grant, and the user is logged
+in if the provider issues a token. This lets clients which cannot follow a
+browser redirect, such as `psql`, authenticate with their SSO credentials.
+
+Local users are matched first, so a QuestDB user whose name also exists in the
+Identity Provider is authenticated against its local password, without involving
+the provider.
+
+Unlike [`acl.oidc.pkce.required`](#acloidcpkcerequired) and
+[`acl.oidc.state.required`](#acloidcstaterequired), this setting is not
+published on the
+[settings endpoint](/docs/security/oidc/#settings-endpoint), so a client cannot
+discover whether the flow is available.
+
+### acl.oidc.state.required
+
+- **Default**: `false`
+- **Reloadable**: no
+
+Tells clients that the `state` parameter is required in the Authorization Code
+Flow, which protects against CSRF attacks. Enable it if the OIDC Provider
+requires the `state` parameter, or to add CSRF protection on top of PKCE.
+
+The [Web Console](/docs/getting-started/web-console/overview/) generates the
+value, sends it in the authorization request, and checks that the provider
+returns it unchanged. See
+[Secret generation](/docs/security/oidc/#1-secret-generation).
+
## Endpoints
+These settings apply only when the OIDC Provider is configured by host. When
+`acl.oidc.configuration.url` is set, QuestDB takes every endpoint from the
+provider's configuration document and the settings below are not used.
+
### acl.oidc.authorization.endpoint
- **Default**: `/as/authorization.oauth2`
@@ -126,6 +247,23 @@ enabled, this flow must also be configured in the OIDC Provider.
OIDC Authorization Endpoint. The default value should work for the Ping
Identity Platform.
+### acl.oidc.device.authorization.endpoint
+
+- **Default**: none
+- **Reloadable**: no
+
+OIDC Device Authorization Endpoint. Unlike the other endpoint settings this one
+has no default, and QuestDB never calls it. QuestDB resolves the endpoint and
+publishes it on the
+[settings endpoint](/docs/security/oidc/#settings-endpoint), for clients which
+implement the
+[Device Authorization Flow](/docs/security/oidc-device-flow/)
+themselves. The official Java, Python, Rust, C, and C++ clients can discover and
+use the published endpoint.
+
+Left unset, and absent from the provider's configuration document, the endpoint
+stays unresolved and the key is omitted from the settings response.
+
### acl.oidc.public.keys.endpoint
- **Default**: `/pf/JWKS`
@@ -135,6 +273,15 @@ JSON Web Key Set (JWKS) Endpoint. Provides the list of public keys used to
decode and validate ID tokens issued by the OIDC Provider. The default value
should work for the Ping Identity Platform.
+The keys are only used to validate tokens when
+[`acl.oidc.groups.encoded.in.token`](#acloidcgroupsencodedintoken) is `true`.
+With the default user info flow QuestDB validates tokens by calling the user
+info endpoint instead.
+
+QuestDB downloads the keys from this endpoint at startup either way, so that the
+cache is never empty. A failure to download them is logged, and does not stop
+the server.
+
### acl.oidc.token.endpoint
- **Default**: `/as/token.oauth2`
@@ -167,13 +314,18 @@ Whether the OIDC provider requires a secure connection. If the OpenID
Provider endpoints do not require TLS, this can be set to `false`. This is
unlikely in production.
+This setting must match the scheme of every OIDC Provider URL QuestDB uses,
+including `acl.oidc.configuration.url` and each endpoint discovered from it.
+A URL whose scheme does not match fails server startup.
+
### acl.oidc.tls.keystore.password
- **Default**: none
- **Reloadable**: no
-Keystore password. Required if a keystore file is configured and is password
-protected.
+Keystore password. Must be set whenever `acl.oidc.tls.keystore.path` is set.
+When OIDC is enabled, setting either one without the other fails server
+startup.
### acl.oidc.tls.keystore.path
@@ -196,22 +348,21 @@ which it connects.
## User and group claims
-### acl.oidc.cache.ttl
-
-- **Default**: `30000`
-- **Reloadable**: no
-
-User info cache entry TTL in milliseconds. QuestDB caches user info responses
-for each valid access token. This setting controls how often the access token
-is validated and user info refreshed.
-
### acl.oidc.groups.claim
-- **Default**: `groups`
+- **Default**: none
- **Reloadable**: no
The name of the custom claim in the user information that contains the
-group memberships of the user.
+group memberships of the user. Required when OIDC is enabled.
+
+If the claim is missing from the user information, or it is an empty list,
+authentication fails. See
+[Mapping user permissions](/docs/security/oidc/#mapping-user-permissions).
+
+A custom name applies to the user info flow. Set this to `groups` when
+[`acl.oidc.groups.encoded.in.token`](#acloidcgroupsencodedintoken) is `true`,
+because the token validator requires that name.
### acl.oidc.groups.encoded.in.token
@@ -222,6 +373,45 @@ When `true`, QuestDB looks for group memberships in the ID token instead of
calling the User Info endpoint. Set to `true` if the OIDC Provider encodes
group memberships directly into the token.
+This also changes which token the client has to send: the ID token when the
+setting is `true`, the access token when it is `false`. QuestDB publishes the
+setting on the
+[settings endpoint](/docs/security/oidc/#settings-endpoint) so that clients can
+[pick the right one](/docs/security/oidc/#which-token-to-send).
+
+It changes how tokens are validated too. In the default user info flow QuestDB
+hands the token to the OIDC Provider on every cache miss, so the provider
+decides whether it is still valid. With this setting enabled QuestDB validates
+the token itself and never asks the provider about it:
+
+| Checked | Not checked |
+| --- | --- |
+| the signature, against the public key named by the token's `kid` | `exp`, the expiry |
+| `aud`, against [`acl.oidc.audience`](#acloidcaudience) | `nbf`, the not-before time |
+| that `sub` and the group memberships are present | `iss`, the issuer |
+
+The validator reads those two claims under the names `sub` and `groups`
+literally, and rejects a token which does not carry both, whatever
+[`acl.oidc.sub.claim`](#acloidcsubclaim) and
+[`acl.oidc.groups.claim`](#acloidcgroupsclaim) are set to. `groups` must be an
+array of strings. Custom claim names apply to the user info flow, so with this
+setting enabled leave `acl.oidc.sub.claim` at its default and set
+`acl.oidc.groups.claim=groups`.
+
+:::caution
+
+An expired token is therefore still accepted. Once issued, a token stays valid
+for as long as the key that signed it is in QuestDB's cache, which is governed
+by [`acl.oidc.public.keys.expiry`](#acloidcpublickeysexpiry) and by how long the
+provider publishes the key. Neither
+[`acl.oidc.cache.ttl`](#acloidccachettl) nor a shorter token lifetime in the
+provider shortens it.
+
+Do not enable this setting where you rely on being able to revoke a token, or on
+the provider's token lifetimes being enforced.
+
+:::
+
### acl.oidc.sub.claim
- **Default**: `sub`
@@ -230,3 +420,71 @@ group memberships directly into the token.
The name of the claim in the user information that contains the user's name.
Could be a username, full name, or email. Displayed in the Web Console and
logged for audit purposes.
+
+If the claim is missing from the user information, or empty, authentication
+fails. The same applies to the claim named by
+[`acl.oidc.groups.claim`](#acloidcgroupsclaim). See
+[Mapping user permissions](/docs/security/oidc/#mapping-user-permissions).
+
+## Caching and buffers
+
+### acl.oidc.cache.ttl
+
+- **Default**: `30000`
+- **Reloadable**: no
+
+User info cache entry TTL in milliseconds, as a plain integer only. QuestDB
+caches user info responses for each valid access token. This setting controls
+how often the access token is validated and user info refreshed.
+
+Set it to `0` to disable the cache, so that every request is checked again. In
+the default user info flow that means a call to the OIDC Provider on every
+request. When
+[`acl.oidc.groups.encoded.in.token`](#acloidcgroupsencodedintoken) is `true`
+QuestDB checks the token locally instead, and contacts the provider only when
+the public keys have to be reloaded.
+
+That local check does not test the token's expiry, so shortening this TTL does
+not shorten how long an issued token is accepted. See
+[`acl.oidc.groups.encoded.in.token`](#acloidcgroupsencodedintoken) for what is
+and is not validated.
+
+### acl.oidc.public.keys.expiry
+
+- **Default**: `120000`
+- **Reloadable**: no
+
+Expiry of the cached JSON Web Key Set (JWKS) in milliseconds. Also accepts a
+duration, such as `2m` or `120s`.
+
+QuestDB caches the public keys used to validate tokens issued by the OIDC
+Provider, and reloads them from the public keys endpoint when the cache expires.
+
+Key rotation does not depend on this setting: a token signed with a key QuestDB
+has not cached triggers an immediate reload. The expiry governs how long a key
+the provider has already withdrawn stays usable, so lower it if signing keys are
+revoked, at the cost of more requests to the endpoint.
+
+Only used when
+[`acl.oidc.groups.encoded.in.token`](#acloidcgroupsencodedintoken) is `true`,
+which is the only case in which QuestDB validates token signatures itself.
+
+### acl.oidc.response.buffer.size
+
+- **Default**: `1M`
+- **Reloadable**: no
+
+Size of the buffer used to receive and parse HTTP responses from the OIDC
+Provider. Accepts a plain byte count, or a value with a `K` or `M` suffix, such
+as `512K`. There is no `G` suffix.
+
+When a request to the OIDC Provider fails, authentication fails with it and the
+reason is logged by the server.
+
+### acl.oidc.string.pool.capacity
+
+- **Default**: `128`
+- **Reloadable**: no
+
+Initial capacity of the string pool used when parsing JSON responses received
+from the OIDC Provider.
diff --git a/documentation/connect/clients/c-and-cpp.md b/documentation/connect/clients/c-and-cpp.md
index 24857c272..ea98fcd73 100644
--- a/documentation/connect/clients/c-and-cpp.md
+++ b/documentation/connect/clients/c-and-cpp.md
@@ -7,6 +7,8 @@ description: "QuestDB C and C++ client: the questdb_db / questdb::pool connectio
import Tabs from "@theme/Tabs"
import TabItem from "@theme/TabItem"
+import OidcDeviceFlowCpp from "../../partials/_oidc.device-flow.cpp.partial.mdx";
+import OidcDeviceFlowC from "../../partials/_oidc.device-flow.c.partial.mdx";
The C and C++ clients ingest and query over
[QWP](/docs/connect/wire-protocols/qwp-ingress-websocket/), a columnar binary
@@ -325,16 +327,58 @@ Because the pool connects lazily, a bad credential surfaces as
Handle it there, not at `connect` (see
[Which errors mean what](#which-errors-mean-what)).
-### Unsupported auth paths
+### OIDC device flow (Enterprise)
-The client supports only HTTP basic auth and static bearer-token auth. The
-following are **not** supported:
+The C and C++ APIs sign in an interactive user with the
+[Device Authorization Flow](/docs/security/oidc-device-flow/).
+They discover the provider endpoints, client ID, scope, and the token QuestDB
+expects from the server's public `/settings` endpoint. Attach the resulting auth
+object to the pool so sender and reader connections share its rotating token:
-| Path | Status | Workaround |
-|---|---|---|
-| OIDC token acquisition or in-band refresh | Not supported. The client does not negotiate with an identity provider and has no callback to refresh a token mid-session. | QuestDB itself supports OIDC; see [OpenID Connect](/docs/security/oidc/). Acquire an access token out-of-band from your IdP, pass it via `token=...`, and rebuild the pool when the token nears expiry. |
-| Mutual TLS (client certificates) | Not supported. The QuestDB server does not negotiate client certificates regardless of client. | Use bearer-token auth over `wss`. See the connect-string reference's [TLS section](/docs/connect/clients/connect-string/#tls). |
-| Token rotation mid-session | Not supported. Credentials are presented once during the WebSocket upgrade and are not re-sent. | On token expiry, close the pool and build a fresh one with the new token. |
+
+
+
+
+
+
+
+
+
+
+
+
+
+The pool retains the auth state and gets a cached or silently refreshed token
+for every connection and reconnect. Silent refresh needs a refresh token, which
+most providers issue only when `offline_access` is among the scopes in
+[`acl.oidc.scope`](/docs/configuration/oidc/#acloidcscope) or in the client's
+own `scope` override. Those transport operations never prompt; they fail with
+`QUESTDB_OIDC_ERROR_INTERACTION_REQUIRED` in C, or
+`questdb::error_kind::interaction_required` in C++, and the application calls
+`sign_in()` / `questdb_oidc_auth_sign_in()` explicitly on the main or UI thread.
+
+The Identity Provider must enable the device grant. For discovery without an
+override, QuestDB must publish a device authorization endpoint, either from the
+provider's configuration document or from
+[`acl.oidc.device.authorization.endpoint`](/docs/configuration/oidc/#acloidcdeviceauthorizationendpoint).
+Otherwise set the builder's `issuer`, or set `client_id`, `scope`, `audience`,
+`token_endpoint` and `device_authorization_endpoint` yourself; in C the same
+names are prefixed with `questdb_oidc_builder_`.
+
+Tokens stay in memory until `.default_file_token_store()` in C++, or
+`questdb_oidc_builder_default_file_token_store()` in C, writes a long-lived
+refresh token to disk as plaintext. See the [complete client
+examples](/docs/security/oidc-device-flow/#official-client-examples) for error handling, the
+sign-in prompt, and the store's location and permissions.
+
+### Other authentication limitations
+
+- Mutual TLS client certificates are not supported because the QuestDB server
+ does not negotiate them. Use bearer-token authentication over `wss`; see the
+ connect-string reference's [TLS section](/docs/connect/clients/connect-string/#tls).
+- A token supplied directly through `token=...` remains fixed. Attach an OIDC
+ auth object for device-flow token refresh, or rebuild the pool when an
+ externally acquired token rotates.
## Headers
diff --git a/documentation/connect/clients/connect-string.md b/documentation/connect/clients/connect-string.md
index c027700dd..9e0d5babe 100644
--- a/documentation/connect/clients/connect-string.md
+++ b/documentation/connect/clients/connect-string.md
@@ -220,6 +220,12 @@ WebSocket upgrade request.
exclusive with `username` / `password`. Token auth avoids the per-request
overhead of basic auth and is the recommended path for Enterprise
deployments.
+- Device-flow OIDC credentials are **not** connect-string keys. The Java,
+ Python, Rust, C and C++ clients take an auth object alongside the connect
+ string, which rotates the token on every reconnect; see
+ [OIDC device flow](/docs/security/oidc-device-flow/). A
+ static `token=` does not rotate, so a reconnect after expiry keeps sending
+ the stale value.
- `auth_timeout_ms` — per-host upper bound on the upgrade response read.
Does not cover TLS handshake or post-upgrade frame reads, which use OS or
hard-coded defaults. Default: `15000` (15 s).
diff --git a/documentation/connect/clients/dotnet.md b/documentation/connect/clients/dotnet.md
index 684ea97cd..76df3b478 100644
--- a/documentation/connect/clients/dotnet.md
+++ b/documentation/connect/clients/dotnet.md
@@ -228,7 +228,7 @@ that purpose; left unset, it inherits `auth_timeout_ms`.
| Path | Status | Workaround |
|---|---|---|
-| OIDC token acquisition or in-band refresh | Not supported by this client. It does not negotiate with an identity provider and has no callback to refresh a token mid-session. | QuestDB itself supports OIDC — see [OpenID Connect](/docs/security/oidc/). Acquire an access token out-of-band from your IdP, pass it via `token=...` above, and rebuild the sender / query client when the token nears expiry. |
+| OIDC token acquisition or in-band refresh | Not supported by this client. It does not negotiate with an identity provider and has no callback to refresh a token mid-session. | QuestDB itself supports OIDC — see [OpenID Connect](/docs/security/oidc/). Acquire a token out-of-band from your IdP, using QuestDB's [settings endpoint](/docs/security/oidc/#settings-endpoint) to discover the provider's authorization and token endpoints and [which token to send](/docs/security/oidc/#which-token-to-send), pass it via `token=...` above, and rebuild the sender / query client when the token nears expiry. To acquire one, run the [Device Authorization Flow](/docs/security/oidc-device-flow/) with an OAuth2 library such as MSAL.NET; QuestDB publishes the device authorization endpoint on the same settings response. |
| Mutual TLS (client certificates) | Not supported. The QuestDB server does not negotiate client certificates regardless of client. | Use bearer-token auth over `wss://`. See the connect-string reference for the canonical statement. |
| Token rotation mid-session | Not supported. Credentials are presented once during the WebSocket upgrade and are not re-sent. | On token expiry, `await sender.DisposeAsync()` and build a fresh sender with the new token. The same applies to `QueryClient`. |
diff --git a/documentation/connect/clients/go.md b/documentation/connect/clients/go.md
index 32c9ac9c0..b66b2748c 100644
--- a/documentation/connect/clients/go.md
+++ b/documentation/connect/clients/go.md
@@ -362,6 +362,17 @@ the ingress and egress WebSocket upgrades. It is a **static credential**: the
client sends exactly the string you pass and never refreshes or renews it.
Acquire it out of band — QuestDB Enterprise issues bearer tokens through its
[OpenID Connect flow](/docs/security/oidc/) — and manage its lifetime yourself.
+QuestDB publishes the provider's authorization and token endpoints on its
+[settings endpoint](/docs/security/oidc/#settings-endpoint), so a client can
+discover them instead of hard coding them. The same response tells the client
+[which token to send](/docs/security/oidc/#which-token-to-send): the access
+token, or the ID token when QuestDB reads group memberships from the token.
+This client does not run an OIDC flow, so acquire the token with an OAuth2
+library: `golang.org/x/oauth2` implements the device grant, and QuestDB
+publishes the device authorization endpoint on the same settings response. The
+[Device Authorization Flow](/docs/security/oidc-device-flow/)
+describes the protocol, including the polling interval, `slow_down`, and the
+device code's expiry.
When the token expires or is rotated, construct a new handle with the new token.
An expired or rejected token surfaces as an authentication failure (see
[Connection-level errors](#connection-level-errors)). It is mutually exclusive
diff --git a/documentation/connect/clients/java.md b/documentation/connect/clients/java.md
index 1df7d8239..dfb9932c9 100644
--- a/documentation/connect/clients/java.md
+++ b/documentation/connect/clients/java.md
@@ -12,6 +12,7 @@ import TabItem from "@theme/TabItem"
import SfDedupWarning from "../../partials/_sf-dedup-warning.partial.mdx"
import CodeBlock from "@theme/CodeBlock"
+import OidcDeviceFlowExample from "../../partials/_oidc.device-flow.java.partial.mdx";
:::note
@@ -397,6 +398,41 @@ The token is sent as an `Authorization: Bearer YOUR_BEARER_TOKEN` header on
both the ingress and egress WebSocket upgrades. It is mutually exclusive with
`username`/`password`.
+### OIDC device flow (Enterprise)
+
+`OidcDeviceAuth` signs in an interactive user with the
+[Device Authorization Flow](/docs/security/oidc-device-flow/).
+It discovers the provider endpoints, client ID, scope, and the token QuestDB
+expects from the server's public `/settings` endpoint. The user can approve the
+sign-in from a browser on any device, so this also works from a container or
+remote notebook kernel:
+
+
+
+Pass `auth::getToken` as a provider instead of putting the current token in the
+connect string. Every new connection and reconnect then receives the cached or
+silently refreshed token. Silent refresh needs a refresh token, which most
+providers issue only when `offline_access` is among the scopes in
+[`acl.oidc.scope`](/docs/configuration/oidc/#acloidcscope) or in the client's
+own `scope` override. A transport call never starts an interactive flow; it
+throws `OidcAuthException`, which carries no distinct interaction-required type,
+so call `signIn()` again on the main or UI thread.
+
+The Identity Provider must enable the device grant. For discovery without an
+override, QuestDB must publish a device authorization endpoint, either from the
+provider's configuration document or from
+[`acl.oidc.device.authorization.endpoint`](/docs/configuration/oidc/#acloidcdeviceauthorizationendpoint).
+Otherwise pin the provider by passing
+`new OidcDeviceAuth.DiscoveryOptions().issuer("https://idp")` as the second
+argument to `fromQuestDB`, or set the client ID, scope and endpoints yourself
+with `OidcDeviceAuth.builder()`.
+
+Tokens stay in memory until a store is attached with
+`DiscoveryOptions.tokenStore(FileTokenStore.atDefaultLocation())`, which writes
+a long-lived refresh token to disk as plaintext. See the [OIDC guide's client
+examples](/docs/security/oidc-device-flow/#official-client-examples) for the sign-in prompt,
+explicit configuration, and the token store's location and permissions.
+
### HTTP basic auth
```java
diff --git a/documentation/connect/clients/nodejs.md b/documentation/connect/clients/nodejs.md
index de60fe561..0df1db8f1 100644
--- a/documentation/connect/clients/nodejs.md
+++ b/documentation/connect/clients/nodejs.md
@@ -91,6 +91,13 @@ When using QuestDB Enterprise, authentication can also be done via REST token.
Please check the [RBAC docs](/docs/security/rbac/#authentication) for more
info.
+This client does not run an OIDC flow. QuestDB Enterprise also accepts an OIDC
+bearer token, which you acquire out of band from your Identity Provider:
+QuestDB publishes the provider's endpoints on its
+[settings endpoint](/docs/security/oidc/#settings-endpoint), and the same
+response tells you [which token to send](/docs/security/oidc/#which-token-to-send).
+See [OpenID Connect](/docs/security/oidc/).
+
## Basic insert
Example: inserting executed trades for cryptocurrencies.
diff --git a/documentation/connect/clients/python.md b/documentation/connect/clients/python.md
index be8d79aa2..e5b0358fe 100644
--- a/documentation/connect/clients/python.md
+++ b/documentation/connect/clients/python.md
@@ -5,6 +5,8 @@ sidebar_label: Python
description: "Use the QuestDB Python client's QuestDB pool for row, DataFrame, and Arrow ingestion plus SQL queries over QWP."
---
+import OidcDeviceFlowExample from "../../partials/_oidc.device-flow.python.partial.mdx";
+
The QuestDB Python client uses one `QuestDB` handle for ingestion and SQL
queries over [QWP](/docs/connect/wire-protocols/qwp-ingress-websocket/). Lease a short-lived sender for each unit of row-building work, bulk-load DataFrames
through the handle, and run SQL with `query()`.
@@ -164,16 +166,45 @@ operating-system certificate store. Override it with configuration keys:
See the [connect string reference](/docs/connect/clients/connect-string/) for
the full grammar.
-### Unsupported auth paths
-
-The client supports only HTTP basic auth and static bearer-token auth. The
-following are **not** supported:
-
-| Path | Status | Workaround |
-| --- | --- | --- |
-| OIDC token acquisition or in-band refresh | Not supported. The client does not negotiate with an identity provider and cannot refresh a token mid-session. | QuestDB itself supports OIDC; see [OpenID Connect](/docs/security/oidc/). Acquire an access token out-of-band from your IdP, pass it via `token=...`, and rebuild the handle when the token nears expiry. |
-| Mutual TLS (client certificates) | Not supported. The QuestDB server does not negotiate client certificates regardless of client. | Use bearer-token auth over `wss`. |
-| Token rotation mid-session | Not supported. The handle keeps the credentials it was built with and presents them on every connection it opens — including reconnects and failover, so an expired token also breaks mid-session reconnection. | On token expiry, close the handle and build a fresh one with the new token. |
+### OIDC device flow (Enterprise)
+
+`OidcDeviceAuth` signs in an interactive user with the
+[Device Authorization Flow](/docs/security/oidc-device-flow/).
+It discovers the provider endpoints, client ID, scope, and the token QuestDB
+expects from the server's public `/settings` endpoint. The default prompt works
+in terminals and remote Jupyter kernels:
+
+
+
+`oidc_auth=auth` keeps shared ownership of the provider and obtains the cached
+or silently refreshed token for every connection and reconnect. It is mutually
+exclusive with a fixed `token=` setting. Silent refresh needs a refresh token,
+which most providers issue only when `offline_access` is among the scopes in
+[`acl.oidc.scope`](/docs/configuration/oidc/#acloidcscope) or in the client's
+own `scope` override. Transport operations never prompt; if they raise
+`OidcInteractionRequired`, importable from `questdb.auth`, call `auth.sign_in()`
+explicitly on the main or UI thread.
+
+The Identity Provider must enable the device grant. For discovery without an
+override, QuestDB must publish a device authorization endpoint, either from the
+provider's configuration document or from
+[`acl.oidc.device.authorization.endpoint`](/docs/configuration/oidc/#acloidcdeviceauthorizationendpoint).
+Otherwise pass `issuer=...`, or set `client_id=`, `scope=`, `audience=`,
+`token_endpoint=` and `device_authorization_endpoint=` on `from_questdb`.
+
+Tokens stay in memory until a store is passed as
+`token_store=FileTokenStore.at_default_location()`, which writes a long-lived
+refresh token to disk as plaintext. See the [OIDC client
+examples](/docs/security/oidc-device-flow/#official-client-examples) for the sign-in prompt,
+explicit configuration, and the store's location and permissions.
+
+### Other authentication limitations
+
+- Mutual TLS client certificates are not supported because the QuestDB server
+ does not negotiate them. Use bearer-token authentication over `wss`.
+- A token supplied directly through `token=...` remains fixed. Use
+ `oidc_auth=...` for device-flow token refresh, or close the handle and build a
+ new one when an externally acquired token rotates.
## The pool
diff --git a/documentation/connect/clients/rust.md b/documentation/connect/clients/rust.md
index 40ec9e4cc..f97eaa9dd 100644
--- a/documentation/connect/clients/rust.md
+++ b/documentation/connect/clients/rust.md
@@ -5,6 +5,8 @@ sidebar_label: Rust
description: "Use the QuestDB Rust connection pool for Buffer, Chunk, Arrow, and Polars ingestion plus streaming SQL queries over QWP."
---
+import OidcDeviceFlowExample from "../../partials/_oidc.device-flow.rust.partial.mdx";
+
The QuestDB Rust client uses a thread-safe `QuestDb` pool for ingestion and SQL
queries over [QWP](/docs/connect/wire-protocols/qwp-ingress-websocket/). Borrow
a short-lived writer or reader for each unit of work, then let `Drop` return its
@@ -163,6 +165,45 @@ choices have feature requirements:
| `tls_roots_password=...` | Unlocks a JKS or PKCS#12 store named by `tls_roots`. |
| `tls_verify=unsafe_off` | Enable `insecure-skip-verify`; use only in controlled tests. |
+### OIDC device flow (Enterprise)
+
+Enable the `oidc` feature to sign in an interactive user with the
+[Device Authorization Flow](/docs/security/oidc-device-flow/):
+
+```toml title="Cargo.toml"
+[dependencies]
+questdb-rs = { version = "7", features = ["oidc"] }
+```
+
+`OidcDeviceAuth::from_questdb` discovers the provider endpoints, client ID,
+scope, and the token QuestDB expects from the public `/settings` endpoint:
+
+
+
+Pass the closure as a provider instead of putting the current token in the
+connect string. Every new connection and reconnect then receives the cached or
+silently refreshed token. Silent refresh needs a refresh token, which most
+providers issue only when `offline_access` is among the scopes in
+[`acl.oidc.scope`](/docs/configuration/oidc/#acloidcscope) or in the client's
+own `scope` override. A transport call never starts an interactive flow; it
+returns a `questdb::Error` with `ErrorCode::AuthError`, whose `err.oidc_error()`
+reports `OidcErrorKind::InteractionRequired` when a new sign-in is needed. Call
+`sign_in()` explicitly on the main or UI thread.
+
+The Identity Provider must enable the device grant. For discovery without an
+override, QuestDB must publish a device authorization endpoint, either from the
+provider's configuration document or from
+[`acl.oidc.device.authorization.endpoint`](/docs/configuration/oidc/#acloidcdeviceauthorizationendpoint).
+Otherwise add `.issuer(...)` to the builder, or set `.client_id()`, `.scope()`,
+`.audience()`, `.token_endpoint()` and `.device_authorization_endpoint()`
+yourself.
+
+Tokens stay in memory until
+`.token_store(FileTokenStore::at_default_location()?)` is added, which writes a
+long-lived refresh token to disk as plaintext. See the [OIDC client
+examples](/docs/security/oidc-device-flow/#official-client-examples) for the sign-in prompt,
+explicit configuration, and the store's location and permissions.
+
## The pool
`QuestDb` owns reusable QWP/WebSocket connections. Create one pool per
@@ -907,6 +948,7 @@ integrations only when your application uses them:
| `ndarray` | No | `Buffer::column_arr` from `ndarray` views. |
| `rust_decimal` / `bigdecimal` | No | Row-buffer decimal values from those crates. Decimal strings need neither feature. |
| `chrono-timestamp` | No | Timestamp values built from `chrono::DateTime`. |
+| `oidc` | No | Interactive OIDC sign-in with the [device flow](/docs/security/oidc-device-flow/). Pulls in `sync-sender-http`, and needs a TLS root source for `https` discovery. |
| `tls-native-certs` | No | TLS validation through the operating-system certificate store. |
| `insecure-skip-verify` | No | `tls_verify=unsafe_off` for controlled testing only. |
| `almost-all-features` | No | Client development and testing with most compatible features. It excludes Arrow and Polars. |
diff --git a/documentation/connect/compatibility/rest-api.md b/documentation/connect/compatibility/rest-api.md
index 033060ff2..c1e6a1b57 100644
--- a/documentation/connect/compatibility/rest-api.md
+++ b/documentation/connect/compatibility/rest-api.md
@@ -794,11 +794,16 @@ A HTTP status code of `400` is returned with the following response body:
## Authentication
-The REST API supports two authentication types:
+The REST API supports three authentication types:
- **HTTP basic authentication**, available in QuestDB Open Source and QuestDB
Enterprise.
- **Token-based authentication**, available in QuestDB Enterprise only.
+- **OIDC bearer tokens**, available in QuestDB Enterprise only. These also
+ travel in the `Authorization: Bearer` header but are issued by an external
+ Identity Provider rather than by QuestDB, so they are a different credential
+ from the REST API token below. See [OpenID Connect](/docs/security/oidc/) and
+ [which token to send](/docs/security/oidc/#which-token-to-send).
:::note
diff --git a/documentation/connect/wire-protocols/qwp-ingress-websocket.md b/documentation/connect/wire-protocols/qwp-ingress-websocket.md
index e27c7ea5e..0d6456210 100644
--- a/documentation/connect/wire-protocols/qwp-ingress-websocket.md
+++ b/documentation/connect/wire-protocols/qwp-ingress-websocket.md
@@ -143,7 +143,10 @@ Supported methods:
[HTTP basic authentication](/docs/connect/compatibility/rest-api/#http-basic-authentication).
- **Token-based auth** (Enterprise only): see
[Authentication via token in QuestDB Enterprise](/docs/connect/compatibility/rest-api/#authentication-via-token-in-questdb-enterprise).
-- **OIDC** (Enterprise only): see [OpenID Connect](/docs/security/oidc/).
+- **OIDC** (Enterprise only): see [OpenID Connect](/docs/security/oidc/). The
+ [settings endpoint](/docs/security/oidc/#settings-endpoint) publishes the
+ provider's authorization and token endpoints, and tells the client
+ [which token to send](/docs/security/oidc/#which-token-to-send).
A failed authentication results in a `401` or `403` HTTP response before the
WebSocket connection is established. No QWP-level auth handshake exists.
diff --git a/documentation/high-availability/failover.md b/documentation/high-availability/failover.md
index 93f58855d..67345bc76 100644
--- a/documentation/high-availability/failover.md
+++ b/documentation/high-availability/failover.md
@@ -284,7 +284,9 @@ The [minimal HTTP server](/docs/operations/logging-metrics/#minimal-http-server)
on port 9003 exposes the same switch to external coordinators. It accepts the
same credentials as the main HTTP server: HTTP basic authentication, a
[REST token](/docs/connect/compatibility/rest-api/#authentication-via-token-in-questdb-enterprise)
-as `Authorization: Bearer`, or an OIDC access token. The principal needs the
+as `Authorization: Bearer`, or an OIDC token — [the access token or the ID
+token](/docs/security/oidc/#which-token-to-send) depending on
+`acl.oidc.groups.encoded.in.token`. The principal needs the
`HTTP` endpoint permission and, for the switch, `SWITCH ROLE`. With access
control disabled no credentials are needed. TLS for this port is configured with
the `http.min.tls.*` settings on the [TLS](/docs/configuration/tls/) page.
diff --git a/documentation/partials/_oidc.device-flow.c.partial.mdx b/documentation/partials/_oidc.device-flow.c.partial.mdx
new file mode 100644
index 000000000..de8dfd238
--- /dev/null
+++ b/documentation/partials/_oidc.device-flow.c.partial.mdx
@@ -0,0 +1,70 @@
+```c
+#include
+#include // questdb_db_borrow_sender + line_sender_buffer
+#include
+#include
+#include
+
+static void show_prompt(void *data, const questdb_oidc_event *event) {
+ (void)data;
+ if (event->kind == QUESTDB_OIDC_EVENT_PROMPT)
+ fprintf(stderr, "Open %.*s and enter %.*s\n",
+ (int)event->verification_uri_len, event->verification_uri,
+ (int)event->user_code_len, event->user_code);
+}
+
+int main(void) {
+ questdb_error *error = NULL;
+ questdb_oidc_builder *builder = NULL;
+ questdb_oidc_auth *auth = NULL;
+ questdb_db *db = NULL;
+ qwp_sender *sender = NULL;
+ line_sender_buffer *buffer = NULL;
+ int status = 1;
+
+ const char *url = "https://questdb.example.com:9000";
+ builder = questdb_oidc_builder_from_questdb(url, strlen(url), &error);
+ if (!builder || !questdb_oidc_builder_event_handler(
+ builder, show_prompt, NULL, NULL, &error))
+ goto done;
+
+ auth = questdb_oidc_builder_build(builder, &error);
+ if (!auth || !questdb_oidc_auth_sign_in(auth, &error))
+ goto done;
+
+ questdb_db_connect_options options;
+ questdb_db_connect_options_init(&options, sizeof options);
+ options.oidc_auth = auth;
+
+ const char *conf = "wss::addr=questdb.example.com:9000;";
+ db = questdb_db_connect_ex(conf, strlen(conf), &options, &error);
+ if (!db) goto done;
+
+ /* Sender and reader borrows use the rotating token. */
+ sender = questdb_db_borrow_sender(db, &error);
+ if (!sender) goto done;
+ buffer = questdb_db_new_buffer(db, &error);
+ if (!buffer) goto done;
+ if (!line_sender_buffer_table(buffer, QDB_TABLE_NAME_LITERAL("trades"), &error)) goto done;
+ if (!line_sender_buffer_symbol(buffer, QDB_COLUMN_NAME_LITERAL("symbol"),
+ QDB_UTF8_LITERAL("ETH-USD"), &error)) goto done;
+ if (!line_sender_buffer_column_f64(buffer, QDB_COLUMN_NAME_LITERAL("price"), 2615.54, &error)) goto done;
+ if (!line_sender_buffer_at_nanos(buffer, line_sender_now_nanos(), &error)) goto done;
+ if (!qwp_sender_flush_buffer_and_wait(sender, buffer, qwpws_ack_level_ok, &error)) goto done;
+ status = 0;
+
+done:
+ if (error) {
+ size_t len = 0;
+ const char *message = questdb_error_msg(error, &len);
+ fprintf(stderr, "OIDC device flow failed: %.*s\n", (int)len, message);
+ questdb_error_free(error);
+ }
+ line_sender_buffer_free(buffer);
+ if (sender) questdb_db_return_sender(db, sender);
+ questdb_db_close(db);
+ questdb_oidc_auth_free(auth);
+ questdb_oidc_builder_free(builder);
+ return status;
+}
+```
diff --git a/documentation/partials/_oidc.device-flow.cpp.partial.mdx b/documentation/partials/_oidc.device-flow.cpp.partial.mdx
new file mode 100644
index 000000000..cdc84f0d6
--- /dev/null
+++ b/documentation/partials/_oidc.device-flow.cpp.partial.mdx
@@ -0,0 +1,33 @@
+```cpp
+#include // pool::borrow_sender
+#include // pool::borrow_reader
+#include
+#include
+
+using namespace questdb::ingress::literals;
+
+int main() {
+ auto auth = questdb::oidc::builder::from_questdb(
+ "https://questdb.example.com:9000")
+ .event_handler([](const questdb::oidc::event_view& event) {
+ if (event.kind() == questdb::oidc::event_kind::prompt)
+ std::cerr << "Open " << event.verification_uri()
+ << " and enter " << event.user_code() << '\n';
+ })
+ .build();
+
+ auth.sign_in(); // the only call which may prompt or open a browser
+ questdb::pool pool{"wss::addr=questdb.example.com:9000;", auth};
+
+ auto sender = pool.borrow_sender();
+ auto buffer = sender.new_buffer();
+ buffer.table("trades"_tn)
+ .symbol("symbol"_cn, "ETH-USD"_utf8)
+ .symbol("side"_cn, "sell"_utf8)
+ .column("price"_cn, 2615.54)
+ .column("amount"_cn, 0.00044)
+ .at(questdb::ingress::timestamp_nanos::now());
+ sender.flush_and_wait(buffer);
+ // pool.borrow_reader() uses the same rotating token.
+}
+```
diff --git a/documentation/partials/_oidc.device-flow.java.partial.mdx b/documentation/partials/_oidc.device-flow.java.partial.mdx
new file mode 100644
index 000000000..361ad24f4
--- /dev/null
+++ b/documentation/partials/_oidc.device-flow.java.partial.mdx
@@ -0,0 +1,24 @@
+```java
+import io.questdb.client.QuestDB;
+import io.questdb.client.Sender;
+import io.questdb.client.cutlass.auth.OidcDeviceAuth;
+
+try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(
+ "https://questdb.example.com:9000")) {
+ auth.signIn(); // the only call which may prompt or open a browser
+
+ try (QuestDB db = QuestDB.connect(
+ "wss::addr=questdb.example.com:9000;",
+ auth::getToken)) {
+ try (Sender sender = db.borrowSender()) {
+ sender.table("trades")
+ .symbol("symbol", "ETH-USD")
+ .symbol("side", "sell")
+ .doubleColumn("price", 2615.54)
+ .doubleColumn("amount", 0.00044)
+ .atNow();
+ }
+ // db.borrowQuery() uses the same rotating token.
+ }
+}
+```
diff --git a/documentation/partials/_oidc.device-flow.python.partial.mdx b/documentation/partials/_oidc.device-flow.python.partial.mdx
new file mode 100644
index 000000000..dc06df19b
--- /dev/null
+++ b/documentation/partials/_oidc.device-flow.python.partial.mdx
@@ -0,0 +1,22 @@
+```python
+import questdb
+from questdb import TimestampNanos
+from questdb.auth import OidcDeviceAuth
+
+with OidcDeviceAuth.from_questdb(
+ "https://questdb.example.com:9000") as auth:
+ auth.sign_in() # the only call which may prompt or open a browser
+
+ with questdb.connect(
+ "wss::addr=questdb.example.com:9000;",
+ oidc_auth=auth) as db:
+ with db.sender() as sender:
+ sender.row(
+ "trades",
+ symbols={"symbol": "ETH-USD", "side": "sell"},
+ columns={"price": 2615.54, "amount": 0.00044},
+ at=TimestampNanos.now(),
+ )
+ sender.flush(wait=True)
+ # db.dataframe() and db.query() use the same rotating token.
+```
diff --git a/documentation/partials/_oidc.device-flow.rust.partial.mdx b/documentation/partials/_oidc.device-flow.rust.partial.mdx
new file mode 100644
index 000000000..b333dfc37
--- /dev/null
+++ b/documentation/partials/_oidc.device-flow.rust.partial.mdx
@@ -0,0 +1,37 @@
+```rust
+use std::sync::Arc;
+use questdb::{
+ ingress::{AckLevel, TimestampNanos},
+ oidc::OidcDeviceAuth,
+ QuestDb,
+};
+
+fn main() -> questdb::Result<()> {
+ let auth = Arc::new(
+ OidcDeviceAuth::from_questdb("https://questdb.example.com:9000")
+ .build()?,
+ );
+ auth.sign_in()?; // the only call which may prompt or open a browser
+
+ let db = QuestDb::connect_with_token_provider(
+ "wss::addr=questdb.example.com:9000;",
+ {
+ let auth = Arc::clone(&auth);
+ move || auth.token()
+ },
+ )?;
+
+ let mut sender = db.borrow_sender()?;
+ let mut buffer = sender.new_buffer();
+ buffer
+ .table("trades")?
+ .symbol("symbol", "ETH-USD")?
+ .symbol("side", "sell")?
+ .column_f64("price", 2615.54)?
+ .column_f64("amount", 0.00044)?
+ .at(TimestampNanos::now())?;
+ sender.flush_buffer_and_wait(&mut buffer, AckLevel::Ok)?;
+ // db.borrow_reader() uses the same rotating token.
+ Ok(())
+}
+```
diff --git a/documentation/security/oidc-device-flow.mdx b/documentation/security/oidc-device-flow.mdx
new file mode 100644
index 000000000..091acede1
--- /dev/null
+++ b/documentation/security/oidc-device-flow.mdx
@@ -0,0 +1,246 @@
+---
+title: OIDC Device Authorization Flow
+description: Sign a user in to QuestDB Enterprise from a command-line application, container, or remote notebook kernel with the OAuth 2.0 device authorization grant, with worked client examples.
+---
+
+import Tabs from "@theme/Tabs";
+import TabItem from "@theme/TabItem";
+import JavaExample from "../partials/_oidc.device-flow.java.partial.mdx";
+import PythonExample from "../partials/_oidc.device-flow.python.partial.mdx";
+import RustExample from "../partials/_oidc.device-flow.rust.partial.mdx";
+import CppExample from "../partials/_oidc.device-flow.cpp.partial.mdx";
+import CExample from "../partials/_oidc.device-flow.c.partial.mdx";
+import { EnterpriseNote } from "@site/src/components/EnterpriseNote"
+
+
+ The Device Authorization Flow signs in an interactive user from a client that
+ cannot receive a browser redirect.
+
+
+The [OAuth 2.0 Device Authorization Grant](https://www.rfc-editor.org/rfc/rfc8628)
+signs in a person without redirecting a browser back to the client. It is a good
+fit for command-line applications, containers, and remote notebook kernels: the
+client displays a verification URL and short code, and the user can authorize
+from a browser on any laptop or phone.
+
+QuestDB does not run the device flow. The client communicates directly with the
+Identity Provider, then presents the resulting bearer token to QuestDB:
+
+```mermaid
+sequenceDiagram
+ participant Client as QuestDB client
+ participant QDB as QuestDB
+ participant IdP as Identity Provider
+ participant User
+ Client->>QDB: GET /settings
+ QDB-->>Client: client ID, scope, endpoints, token mode
+ Client->>IdP: Request device and user codes
+ IdP-->>Client: device_code, user_code, verification URI
+ Client->>User: Display URL and code
+ User->>IdP: Sign in and approve in a browser
+ loop Until approved or expired
+ Client->>IdP: Poll token endpoint
+ IdP-->>Client: authorization_pending / tokens
+ end
+ Client->>QDB: Authorization: Bearer selected token
+```
+
+The client must respect the polling interval, `slow_down` responses, and the
+device code's expiry. The official clients handle those protocol details,
+choose the access or ID token according to
+[`acl.oidc.groups.encoded.in.token`](/docs/security/oidc/#which-token-to-send), cache it in memory,
+and refresh it silently when the provider issues a refresh token. They never
+send the device code or the user's Identity Provider password to QuestDB.
+
+## Configure the provider and QuestDB
+
+Before using the flow:
+
+1. Register the application as a public client with the Identity Provider and
+ enable the device authorization grant on it. Each application which
+ integrates via OIDC should have its own Client Id, so that it can be told
+ apart from QuestDB in the provider's audit log and given its own policies.
+ Pass that Client Id to the client explicitly, as described under
+ [Explicit configuration and endpoint pinning](#explicit-configuration-and-endpoint-pinning).
+2. Make the device authorization endpoint available to clients. For the
+ zero-configuration examples below, `acl.oidc.configuration.url` must resolve
+ a provider document containing `device_authorization_endpoint`, or the
+ host-based configuration must set
+ [`acl.oidc.device.authorization.endpoint`](/docs/configuration/oidc/#acloidcdeviceauthorizationendpoint).
+3. Keep `openid` in `acl.oidc.scope`. Add `offline_access` if your provider
+ requires that scope before issuing a refresh token to a device-flow client.
+
+For example, a host-based configuration can add:
+
+```ini title="server.conf"
+acl.oidc.scope=openid offline_access
+acl.oidc.device.authorization.endpoint=/as/device_authz.oauth2
+```
+
+The exact endpoint path and refresh-token scope are provider-specific. With
+configuration-document discovery, do not set the endpoint property: all
+individual endpoint settings are ignored in that mode.
+
+If QuestDB does not publish the device authorization endpoint, pin the provider
+with the client's `issuer` option so the client can fetch the provider's
+`/.well-known/openid-configuration` document, or configure the client ID and
+both endpoints explicitly.
+
+## Official client examples
+
+Each example discovers the OIDC client ID, scope, token-selection mode, and
+provider endpoints from QuestDB's [settings endpoint](/docs/security/oidc/#settings-endpoint), then
+signs in before opening the database connection.
+
+Discovering the client ID means reusing QuestDB's own registration, which keeps
+the examples short but gives the application no separate identity at the
+provider. That is fine for evaluation; in production register the application
+and set its Client Id explicitly, as described below.
+
+The discovery URL must use `https`: reaching QuestDB over plaintext would let an
+attacker replace the advertised Identity Provider endpoints and redirect the
+device code and the refresh token, so the clients reject it rather than warn.
+The Rust, C, C++ and Python clients accept loopback `http` for local
+development; the Java client does not. To use a plaintext non-loopback host
+anyway, opt in with `allowInsecureTransport(true)` (Java),
+`.allow_insecure_transport(true)` (Rust),
+`questdb_oidc_builder_allow_insecure_transport()` (C),
+`allow_insecure_transport()` (C++) or `insecure=True` (Python).
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Enable the `oidc` crate feature:
+
+```toml title="Cargo.toml"
+[dependencies]
+questdb-rs = { version = "7", features = ["oidc"] }
+```
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+The authentication object owns the token state. Pass it as a rotating provider,
+as shown above, instead of copying the current token into the connection string;
+otherwise a reconnect after token expiry will keep sending the stale value.
+
+Device flow always requires a person to approve the sign-in. For unattended
+services, scheduled jobs, or CI, use a service-account token or a provider flow
+intended for machine identities instead.
+
+### The sign-in prompt
+
+Signing in renders the verification URL and the user code, then blocks until the
+user approves or the device code expires. Where that text goes, and whether the
+call runs at all without a terminal, differs by client:
+
+| Client | Default prompt | Without a terminal |
+| --- | --- | --- |
+| Java | prints to `System.out`, then tries to open a browser | runs anyway; the client performs no terminal check |
+| Python | prints to stderr, or to the frontend inside an IPython kernel | fails, unless the kernel accepts stdin |
+| Rust, C, C++ | prints to stderr | fails with an interaction-required error |
+
+Redirecting output therefore hides the code in Java, while a container with no
+TTY fails outright in the other four. Override the default to render the
+challenge elsewhere, or to declare the process interactive anyway:
+
+| Client | Custom prompt | Force interactive |
+| --- | --- | --- |
+| Java | `prompt(...)`, or `DeviceCodePrompt.SYSTEM_OUT` to skip the browser launch | not applicable |
+| Python | `renderer=` | `interactive=True` |
+| Rust | `.renderer(...)` | `.interactive(true)` |
+| C | `questdb_oidc_builder_event_handler()` | `questdb_oidc_builder_interactive()` |
+| C++ | `.event_handler(...)` | `.interactive(true)` |
+
+The prompt's text fields are display-safe. Where a client opens the URL or makes
+it clickable, use the separately vetted browser target instead: `browser_target`
+on the C event struct, `event_view::browser_target()` in C++.
+
+### Explicit configuration and endpoint pinning
+
+Discovery supplies the client ID, scope, audience, token-selection mode, and
+endpoints. Set any of them explicitly when the application has its own
+registration with the provider, or when QuestDB does not publish what the client
+needs:
+
+| Setting | Set it when |
+| --- | --- |
+| `client_id` | the application has its own registration, which is the recommended setup |
+| `scope` | the provider requires `offline_access` before it issues a refresh token |
+| `audience` | the provider requires one, or QuestDB validates `aud` against a non-default [`acl.oidc.audience`](/docs/configuration/oidc/#acloidcaudience). QuestDB does not publish that key, so discovery cannot supply it |
+| `issuer` | QuestDB publishes no device authorization endpoint, so the client reads the provider's `/.well-known/openid-configuration` instead |
+| `token_endpoint`, `device_authorization_endpoint` | pinning both endpoints directly rather than through an issuer |
+| `groups_in_token` | overriding which of the two tokens the client selects |
+
+Java exposes `issuer`, `prompt`, `tokenStore`, `tlsConfig` and
+`allowInsecureTransport` on `OidcDeviceAuth.DiscoveryOptions`, and the full set
+on `OidcDeviceAuth.builder()`, in camel case. Python passes them as keyword
+arguments to `from_questdb`. Rust and C++ are builder methods in snake case, and
+C prefixes the same names with `questdb_oidc_builder_`.
+
+### Token persistence
+
+Tokens stay in memory unless a file token store is enabled. The store writes the
+access, ID and long-lived refresh tokens as unencrypted JSON, so turn it on only
+where that at-rest exposure is acceptable:
+
+| Client | Default location | A directory you choose |
+| --- | --- | --- |
+| Java | `FileTokenStore.atDefaultLocation()` | `FileTokenStore.at(path)` |
+| Python | `FileTokenStore.at_default_location()` | `FileTokenStore.at(path)` |
+| Rust | `FileTokenStore::at_default_location()?` | `FileTokenStore::at(path)` |
+| C | `questdb_oidc_builder_default_file_token_store()` | `questdb_oidc_builder_file_token_store()` |
+| C++ | `.default_file_token_store()` | `.file_token_store(directory)` |
+
+Java, Python and Rust attach the store with `tokenStore(...)`, `token_store=`
+and `.token_store(...)` respectively; the C and C++ calls above are builder
+methods and attach it themselves.
+
+The default directory is `$HOME/.questdb/oidc-tokens/`, overridden by the
+`questdb.client.oidc.token.store.dir` environment variable. Java reads that same
+name as a JVM system property rather than an environment variable, and falls
+back to `${user.home}/.questdb/oidc-tokens/`. On Unix the clients create token
+files with mode `0600` and the store directory with `0700`; on other platforms
+the directory's existing ACL governs access, so restrict it before enabling the
+store.
+
+### Interaction-required errors
+
+A transport call never prompts. When the cached token cannot be refreshed
+silently, the call fails instead, and the application has to sign in again on
+the main or UI thread:
+
+| Client | How it surfaces |
+| --- | --- |
+| Python | `OidcInteractionRequired`, importable from `questdb.auth` |
+| Rust | a `questdb::Error` carrying `ErrorCode::AuthError`; read `err.oidc_error()` and match on `OidcErrorKind::InteractionRequired` |
+| C | error kind `QUESTDB_OIDC_ERROR_INTERACTION_REQUIRED` |
+| C++ | `questdb::error_kind::interaction_required` |
+| Java | `OidcAuthException`, with no distinct interaction-required type to match on |
+
+In C++ a failure raised through an attached sender arrives as
+`questdb::ingress::line_sender_error` carrying `oidc_diagnostic()`, not as
+`questdb::oidc::error`, so catch the common base `const questdb::error&` to
+handle both.
diff --git a/documentation/security/oidc.mdx b/documentation/security/oidc.mdx
index 4e32f3521..1d76f1f6e 100644
--- a/documentation/security/oidc.mdx
+++ b/documentation/security/oidc.mdx
@@ -1,9 +1,11 @@
---
title: OpenID Connect (OIDC) Integration
-description: Configure OpenID Connect (OIDC) integration with external Identity Providers for SSO authentication in QuestDB Enterprise Web Console.
+description: Integrate QuestDB Enterprise with an external OIDC Identity Provider for Web Console SSO, device-flow client authentication, PGWire token authentication, and group mapping.
---
import Screenshot from "@theme/Screenshot";
+import Tabs from "@theme/Tabs";
+import TabItem from "@theme/TabItem";
import { EnterpriseNote } from "@site/src/components/EnterpriseNote"
@@ -74,7 +76,9 @@ endpoints.
The database, in OAuth2/OIDC terms the _protected resource_ or _resource
server_.
-Only processes requests which contain a valid access token.
+Only processes requests which contain the bearer token selected by its OIDC
+configuration: normally the access token, or the ID token when group memberships
+are encoded in it.
## Authentication and Authorization Flow
@@ -121,9 +125,24 @@ been authenticated already:
Identity Provider for authentication.
```bash title="Authorization code request example"
-https://oidc.provider:443/as/authorization.oauth2?client_id=questdb&response_type=code&scope=openid&redirect_uri=https%3A%2F%2Fquestdb.host%3A9000&code_challenge=IwZ-WuypAY3fMtvismbj1MQUe5CzMgrBa87nYcgFoLQ&code_challenge_method=S256
+https://oidc.provider:443/as/authorization.oauth2?client_id=questdb&response_type=code&scope=openid&redirect_uri=https%3A%2F%2Fquestdb.example.com%3A9000&code_challenge=IwZ-WuypAY3fMtvismbj1MQUe5CzMgrBa87nYcgFoLQ&code_challenge_method=S256
```
+:::note
+
+Some Identity Providers require the `state` parameter in the authorization
+request. It is another random value generated by the client, which the OIDC
+Provider returns unchanged together with the authorization code. Checking it
+protects against CSRF attacks.
+
+If your provider requires it, set
+[`acl.oidc.state.required`](/docs/configuration/oidc/#acloidcstaterequired) to
+`true`. The [Web Console](/docs/getting-started/web-console/overview/) then
+generates the `state` parameter, sends it in the authorization request, and
+validates the value returned by the provider.
+
+:::
+
### 2. Prove identity
Next, the user must prove its identity.
@@ -172,7 +191,7 @@ The Authorization Server redirects the user back to the
[Web Console](/docs/getting-started/web-console/overview/) with the _authorization code_:
```bash title="Authorization code response example"
-https://questdb.host:9000/?code=1L344XEY5XRka1j4ySNa8bVQSLf71as9uGLEuv_A
+https://questdb.example.com:9000/?code=1L344XEY5XRka1j4ySNa8bVQSLf71as9uGLEuv_A
```
### 5. Credential request
@@ -194,7 +213,7 @@ which requested the authorization code, and it was not stolen:
```bash title="Token request example"
POST https://oidc.provider:443/as/token.oauth2 HTTP/1.1
Content-Type: application/x-www-form-urlencoded
-grant_type=authorization_code&code=1L344XEY5XRka1j4ySNa8bVQSLf71as9uGLEuv_A&client_id=questdb&&redirect_uri=https%3A%2F%2Fquestdb.host%3A9000&code_verifier=uGZh4sQffXLgRna7D-jtEAkuXzp7Lm_okZXBljzP38coAD44kEheIaz7Pdh98KxYtYLZHNiQPCczQYeF
+grant_type=authorization_code&code=1L344XEY5XRka1j4ySNa8bVQSLf71as9uGLEuv_A&client_id=questdb&&redirect_uri=https%3A%2F%2Fquestdb.example.com%3A9000&code_verifier=uGZh4sQffXLgRna7D-jtEAkuXzp7Lm_okZXBljzP38coAD44kEheIaz7Pdh98KxYtYLZHNiQPCczQYeF
```
### 6. Credentials received
@@ -225,20 +244,28 @@ The validity of the tokens are configurable inside the OIDC Provider.
With the tokens, the Web Console can interact with the database.
-The access token is in the header of every request sent to QuestDB.
+A token is in the header of every request sent to QuestDB. Which of the two goes
+there depends on where QuestDB reads the group memberships from, see
+[Which token to send](#which-token-to-send).
+
+:::note
-> **Worried about exposing the token?** It is rather opaque and does not contain
-> user details.
+Worried about exposing the token? An access token is usually opaque and carries
+no user details, though some providers issue a JWT here too. An ID token always
+does: it is a JWT whose payload is base64 encoded, not encrypted, so treat it as
+you would the user's directory record.
+
+:::
To carry out permission checks, the database has to know more about the user.
For this, QuestDB has a User Info Cache.
-If it finds a valid entry with the access token in the cache, steps 8 and 9 are
+If it finds a valid entry for the token in the cache, steps 8 and 9 are
skipped:
```bash title="Query request example"
-https://questdb.host:9999/exec?query=select%20current_user()
+https://questdb.example.com:9000/exec?query=select%20current_user()
Authorization: Bearer gslpJtzmmi6RwaPSx0dYGD4tEkom
```
@@ -303,6 +330,145 @@ and then sends the results back:
}
```
+## Settings endpoint
+
+QuestDB publishes the parts of its OIDC configuration that a client needs in
+order to start an authentication flow. Any client can read them from the
+settings endpoint, which requires no authentication:
+
+```bash title="Settings request example"
+curl https://questdb.example.com:9000/settings
+```
+
+The OIDC-related entries of the response look like this, alongside other server
+settings:
+
+```json title="Settings response example"
+{
+ "config": {
+ "acl.enabled": true,
+ "acl.basic.auth.realm.enabled": false,
+ "acl.oidc.enabled": true,
+ "acl.oidc.pkce.required": true,
+ "acl.oidc.state.required": false,
+ "acl.oidc.groups.encoded.in.token": false,
+ "acl.oidc.client.id": "questdb",
+ "acl.oidc.redirect.uri": "https://questdb.example.com:9000",
+ "acl.oidc.scope": "openid",
+ "acl.oidc.authorization.endpoint": "https://oidc.provider:443/as/authorization.oauth2",
+ "acl.oidc.token.endpoint": "https://oidc.provider:443/as/token.oauth2",
+ "acl.oidc.device.authorization.endpoint": "https://oidc.provider:443/as/device_authz.oauth2"
+ },
+ "preferences.version": 0,
+ "preferences": {}
+}
+```
+
+Each key is the name of the
+[configuration option](/docs/configuration/oidc/) it carries. The
+[Web Console](/docs/getting-started/web-console/overview/) reads them to build
+its authorization request, and any other client can do the same instead of
+hard coding the provider's details.
+
+| Key | Type | Present |
+| --- | --- | --- |
+| `acl.enabled` | boolean | always |
+| `acl.basic.auth.realm.enabled` | boolean | always |
+| `acl.oidc.enabled` | boolean | always |
+| `acl.oidc.pkce.required` | boolean | always |
+| `acl.oidc.state.required` | boolean | always |
+| `acl.oidc.groups.encoded.in.token` | boolean | always |
+| `acl.oidc.client.id` | string or `null` | always |
+| `acl.oidc.redirect.uri` | string or `null` | always |
+| `acl.oidc.scope` | string | always |
+| `acl.oidc.authorization.endpoint` | string | when the endpoint is resolved |
+| `acl.oidc.token.endpoint` | string | when the endpoint is resolved |
+| `acl.oidc.device.authorization.endpoint` | string | when the endpoint is configured or discovered |
+
+The endpoints are absolute URLs, resolved either from `acl.oidc.host` and the
+[endpoint settings](/docs/configuration/oidc/#endpoints), or from the provider's
+configuration document when `acl.oidc.configuration.url` is set.
+[`acl.oidc.device.authorization.endpoint`](/docs/configuration/oidc/#acloidcdeviceauthorizationendpoint)
+is present when a device authorization endpoint has been configured, or when the
+provider advertises one. QuestDB publishes it without using it, for clients
+which implement the [Device Authorization Flow](/docs/security/oidc-device-flow/)
+themselves.
+
+`acl.oidc.redirect.uri` is `null` unless
+[`acl.oidc.redirect.uri`](/docs/configuration/oidc/#acloidcredirecturi) is set.
+A client should then fall back to its own location, as the Web Console does.
+
+QuestDB advertises `acl.oidc.pkce.required` and `acl.oidc.state.required`, and
+enforces neither. The client generates the code verifier and the `state` value;
+the provider checks the verifier, and the client checks the `state` value it
+gets back.
+
+:::note
+
+Read `acl.oidc.enabled` before any of the other keys. The `acl.oidc.*` entries
+are published whether or not OIDC is enabled. With OIDC disabled and
+`acl.oidc.host` in use, the endpoint URLs are built from the defaults rather
+than from a real provider; with `acl.oidc.configuration.url` set they are not
+resolved at all, and the endpoint keys are absent from the response.
+
+OIDC also requires [`acl.enabled`](/docs/configuration/iam/#aclenabled) to be
+`true`, which the same response carries. When access control is disabled,
+`acl.oidc.enabled` still reports the configured value while no OIDC
+authentication takes place, so read both keys.
+
+:::
+
+The path of the endpoint is set by
+[`http.context.settings`](/docs/configuration/http-server/#httpcontextsettings).
+Setting it adds paths rather than moving the endpoint, so the default path keeps
+working too. That default is `/settings` only while
+[`http.context.web.console`](/docs/configuration/http-server/#httpcontextwebconsole)
+is at its default, as the settings path follows the Web Console context path.
+A client which cannot assume both are at their defaults should make the path
+configurable.
+
+## Which token to send
+
+`acl.oidc.groups.encoded.in.token` tells the client which of the tokens returned
+by the provider belongs in the `Authorization: Bearer` header:
+
+| Value | Token to send | How QuestDB validates it |
+| --- | --- | --- |
+| `false`, the default | the access token | by calling the user info endpoint |
+| `true` | the ID token | locally, by checking its signature, audience, and claims |
+
+Sending the wrong one fails authentication with `401 Unauthorized` and the
+reason in the server log. With
+[`acl.oidc.groups.encoded.in.token`](/docs/configuration/oidc/#acloidcgroupsencodedintoken)
+enabled the token has to be a JWT, because QuestDB reads the group memberships
+straight out of its payload. The
+[Web Console](/docs/getting-started/web-console/overview/) picks the token this
+way, and so should any other client.
+
+:::caution
+
+With `acl.oidc.groups.encoded.in.token` enabled, QuestDB checks the token's
+signature, its `aud` claim, and that `sub` and the group memberships are
+present. It does not check `exp`, `nbf` or `iss`, so an expired token is still
+accepted, and the provider's token lifetimes are not enforced. Do not enable the
+setting where you rely on being able to revoke a token. See
+[`acl.oidc.groups.encoded.in.token`](/docs/configuration/oidc/#acloidcgroupsencodedintoken)
+for the full list of what is and is not validated.
+
+:::
+
+## Device Authorization Flow
+
+For command-line applications, containers, and remote notebook kernels, where
+no browser redirect can reach the client, QuestDB Enterprise supports the
+OAuth 2.0 Device Authorization Grant. QuestDB does not run the flow: the client
+talks to the Identity Provider directly and presents the resulting bearer
+token.
+
+See [OIDC Device Authorization Flow](/docs/security/oidc-device-flow/) for the
+protocol walkthrough, what to configure first, and worked examples for the
+Java, Python, Rust, C and C++ clients.
+
## Interactive clients
Any interactive client - a UI, Jupyter notebook, CLI - can integrate with an
@@ -326,7 +492,13 @@ possible flows to request an access token.:
flow **(Recommended, more secure)**
2. [Implicit](https://openid.net/specs/openid-connect-core-1_0.html#ImplicitFlowAuth)
- flow
+ flow **(deprecated)**. It cannot use PKCE, so it does not satisfy
+ [`acl.oidc.pkce.required`](/docs/configuration/oidc/#acloidcpkcerequired),
+ which is `true` by default.
+
+The client can read the authorization and token endpoints, the client id and
+the scopes from the [settings endpoint](#settings-endpoint) instead of hard
+coding them.
The Web Console implements the
[Authorization Code Flow with PKCE](https://oauth.net/2/pkce), which is a
@@ -334,8 +506,9 @@ special version of the Authorization Code flow designed for mobile apps and
single page applications.
Regardless of which flow is used by the web or mobile application, the requested
-access token can be used for authentication and authorization when communicating
-with QuestDB as explained in the [above 7th step](#7-database-access).
+token can be used for authentication and authorization when communicating with
+QuestDB. Select the access or ID token as described in
+[Which token to send](#which-token-to-send).
### Jupyter notebook
@@ -346,11 +519,14 @@ The OAuthenticator documentation also contains
[examples](https://oauthenticator.readthedocs.io/en/latest/tutorials/provider-specific-setup/index.html)
using different identity providers.
-If Jupyter notebooks are used without JupyterHub, one option for OAuth2
-integration is to use the
-Resource Owner Password Credentials (ROPC) flow.
-It is likely that enabling this flow in your OAuth2 provider will require
-additional setup.
+If Jupyter notebooks are used without JupyterHub, use the official Python
+client's [Device Authorization Flow](/docs/security/oidc-device-flow/). It works when
+the browser and notebook kernel are on different machines and does not put the
+user's password in the notebook.
+
+For tooling without device-flow support, a last-resort option is the
+Resource Owner Password Credentials (ROPC) flow. Enabling it usually
+requires additional provider configuration.
:::caution
@@ -358,7 +534,13 @@ The Resource Owner Password Credentials flow is legacy, and should be used as a
:::
-We can use the code below to acquire an access token in our notebook:
+The ROPC snippets below assume
+`acl.oidc.groups.encoded.in.token=false` and therefore send the access token. If
+the setting is `true`, the client must send the ID token instead, and the
+provider must issue one for the password grant. The official device-flow client
+selects the correct token automatically.
+
+As a fallback, the code below acquires an access token with ROPC:
```python
from urllib import request, parse
@@ -502,8 +684,13 @@ with pg.connect(conn_str, autocommit=True) as connection:
### CLI, standalone applications
-When using CLI tools, such as `psql`, or standalone applications like Microsoft
-Access, the best option may be the Resource Owner Password Credentials flow.
+For a CLI or standalone application built with an official Java, Python, Rust,
+C, or C++ client, use the
+[Device Authorization Flow](/docs/security/oidc-device-flow/).
+Tools such as `psql` and Microsoft Access cannot run the flow themselves; for
+those tools, acquire a token separately and use
+[PGWire token authentication](#oidc-for-the-pgwire-endpoint), or enable the
+legacy Resource Owner Password Credentials flow.
The user logs in with their SSO credentials, and the server validates the
details with the OAuth2 provider:
@@ -525,39 +712,79 @@ client for ingesting data. It is practical to manage their credentials via an
OAuth2 provider too.
As seen in the Jupyter notebook examples, the clients can request a token
-themselves and then use it to authorise data ingestion:
+themselves and then use it to authorise data ingestion. QuestDB publishes the
+provider's token endpoint on the [settings endpoint](#settings-endpoint), so it
+does not have to be hard coded, and the same response tells the client
+[which token to send](#which-token-to-send).
+
+The client id and the scopes stay the client's own. `acl.oidc.client.id` and
+`acl.oidc.scope` describe QuestDB's registration with the provider, not the
+job's. As noted in [Architecture overview](#architecture-overview), each
+application which integrates via OIDC should be given a different Client Id, so
+that the two can be told apart in the provider's audit log and given different
+policies.
+
+:::caution
+
+Both requests must go over `https`. The settings response names the URL this
+code posts the user's password to, so anyone able to tamper with a plaintext
+response can redirect the password grant to a host of their choosing. The same
+applies to the connection below, which carries the resulting token.
+
+:::
```python
-import json
import os
import requests
import pandas as pd
+import questdb
from dotenv import load_dotenv
-from questdb.ingress import Sender
load_dotenv()
user = os.environ.get("username")
pwd = os.environ.get("password")
-token_endpoint = "https://oidc.provider:443/as/token.oauth2"
-response = requests.post(token_endpoint,
+# this job's own registration in the OIDC Provider, not QuestDB's;
+# the openid scope is what makes the provider issue an ID token
+client_id = os.environ.get("client_id")
+scope = "openid"
+
+settings = requests.get("https://questdb.example.com:9000/settings").json()["config"]
+if not settings.get("acl.oidc.enabled"):
+ raise SystemExit("OIDC is not enabled on this QuestDB instance")
+
+response = requests.post(settings["acl.oidc.token.endpoint"],
data={"grant_type": "password",
- "client_id": "testclient",
+ "client_id": client_id,
"username": user,
"password": pwd,
- "scope": "openid"},
+ "scope": scope},
headers={"Content-Type": "application/x-www-form-urlencoded"})
+response.raise_for_status()
+tokens = response.json()
+
+# QuestDB reads the group memberships from the ID token when this is enabled
+token_key = "id_token" if settings["acl.oidc.groups.encoded.in.token"] else "access_token"
+if token_key not in tokens:
+ raise SystemExit(f"the provider did not issue an {token_key} for the password grant")
+token = tokens[token_key]
+
+conf = f"wss::addr=questdb.example.com:9000;token={token};"
+with questdb.connect(conf) as db:
+ df = pd.read_csv("trades.csv")
+ df["timestamp"] = pd.to_datetime(df["timestamp"])
+ db.dataframe(df, table_name="trades", symbols=["symbol"], at="timestamp")
+```
-response_body = response.content.decode("utf-8")
-tokens = json.loads(response_body)
-access_token = tokens["access_token"]
+:::note
-conf = f"http::addr=localhost:9000;token={access_token};"
-with Sender.from_conf(conf) as sender:
- df = pd.read_csv("data.csv")
- df["ts"] = pd.to_datetime(df["ts"])
- sender.dataframe(df, table_name="foo", at="ts")
-```
+The Resource Owner Password Credentials flow returns an ID token only if the
+provider issues one for the password grant, and only when `openid` is among the
+requested scopes. Check that the provider does before relying on
+[`acl.oidc.groups.encoded.in.token`](/docs/configuration/oidc/#acloidcgroupsencodedintoken)
+with a non-interactive client.
+
+:::
Alternatively, a user may rely on QuestDB to authenticate them via the OAuth2
provider when the Resource Owner Password Credentials flow is enabled on the
@@ -566,18 +793,18 @@ server side:
```python
import os
import pandas as pd
+import questdb
from dotenv import load_dotenv
-from questdb.ingress import Sender
load_dotenv()
user = os.environ.get("username")
pwd = os.environ.get("password")
-conf = f"http::addr=localhost:9000;username={user};password={pwd};"
-with Sender.from_conf(conf) as sender:
- df = pd.read_csv("data.csv")
- df["ts"] = pd.to_datetime(df["ts"])
- sender.dataframe(df, table_name="foo", at="ts")
+conf = f"wss::addr=questdb.example.com:9000;username={user};password={pwd};"
+with questdb.connect(conf) as db:
+ df = pd.read_csv("trades.csv")
+ df["timestamp"] = pd.to_datetime(df["timestamp"])
+ db.dataframe(df, table_name="trades", symbols=["symbol"], at="timestamp")
```
## OIDC for the PGWire endpoint
@@ -658,12 +885,16 @@ QuestDB works the list of external groups out from the User Info response
message.
If we take the example used earlier, we will see that the message contains a
-claim called `groups`. This name is configurable in QuestDB.
-
-If the groups claim is missing or it is an empty list, the user cannot access
-the database.
+claim called `groups`. Its name is set with
+[`acl.oidc.groups.claim`](/docs/configuration/oidc/#acloidcgroupsclaim), which
+has no default and must be set whenever OIDC is enabled.
-Although the user is authenticated, they have no permissions at all.
+If the groups claim is missing or it is an empty list, authentication fails. The
+same happens when the claim named by
+[`acl.oidc.sub.claim`](/docs/configuration/oidc/#acloidcsubclaim) is missing or
+empty. Over HTTP QuestDB replies with `401 Unauthorized`, while on the PGWire
+endpoint the client receives an authentication error. Either way the reason is
+only visible in the server log.
The user has to have at least the `HTTP` permission to be able to successfully
login via the [Web Console](/docs/getting-started/web-console/overview/).
@@ -1035,9 +1266,8 @@ The value is `memberOf`.
#### Enable Resource Owner Password Credentials (ROPC) flow
-As described in the
-[OIDC operations document](/docs/security/oidc/#enable-ropc)
-tools - such as `psql` - can be integrated with the OIDC provider using the ROPC flow.
+As described under [Enable ROPC](#enable-ropc), tools such as `psql` can be
+integrated with the OIDC provider using the ROPC flow.
When setting this flow up, enable the Resource Owner Password Credentials flow in the
client settings.
@@ -1050,10 +1280,41 @@ to the existing PCV.
Then select the `username` attribute of the PCV as `USER_KEY`.
+#### QuestDB configuration for PingFederate
+
+The below should be set in QuestDB's `server.conf`:
+
+```ini title="server.conf"
+# enable OIDC
+acl.oidc.enabled=true
+
+# hostname of the PingFederate server
+acl.oidc.host=pingfederate.host
+
+# the client id picked when setting up the client above
+acl.oidc.client.id=questdb
+
+# the claim which contains the user's group memberships
+acl.oidc.groups.claim=groups
+
+# enable ROPC flow
+# optional, required only if ROPC is enabled in PingFederate
+acl.oidc.ropc.flow.enabled=true
+```
+
+The endpoint defaults listed under
+[Endpoints](/docs/configuration/oidc/#endpoints) already match the PingFederate
+paths, so they do not have to be set.
+
+Alternatively, set `acl.oidc.configuration.url` instead of `acl.oidc.host` and
+let QuestDB discover the endpoints from PingFederate. The two are mutually
+exclusive, and the endpoint settings are not used when the configuration URL is
+set.
+
#### Confirm QuestDB mappings and login
-QuestDB requires a mapping, as laid out in the
-[OIDC operations document](/docs/security/oidc/#mapping-user-permissions).
+QuestDB requires a mapping, as laid out under
+[Mapping user permissions](#mapping-user-permissions).
If a given user has the HTTP permission, they will be able to now login via the
[Web Console](/docs/getting-started/web-console/overview/).
@@ -1187,8 +1448,7 @@ validate the ID token, and take the group information from there.
QuestDB authorization relies on receiving the group memberships of the user.
Entra ID groups should be mapped to QuestDB groups, and permissions can be
granted to the QuestDB groups. Detailed information about group mappings can
-be found in the [OIDC integration](/docs/security/oidc/#user-permissions)
-documentation.
+be found under [User permissions](#user-permissions).
+:::caution
+
+This setup relies on
+[`acl.oidc.groups.encoded.in.token`](/docs/configuration/oidc/#acloidcgroupsencodedintoken),
+because Entra ID cannot serve the group memberships from its User Info
+endpoint. QuestDB then validates the ID token itself and never asks Entra ID
+about it, which means it does not check the token's expiry: an issued token is
+accepted for as long as the key that signed it stays in QuestDB's cache. Read
+that option before going to production, and note that clients must send the ID
+token here, not the access token.
+
+:::
+
#### Map groups and grant permissions
Now we can start QuestDB, and login with the built-in admin to create
diff --git a/documentation/security/rbac.md b/documentation/security/rbac.md
index 5b10f3c26..77c14eaaf 100644
--- a/documentation/security/rbac.md
+++ b/documentation/security/rbac.md
@@ -314,13 +314,20 @@ dropped, all members lose the permissions they inherited from that group.
width={745}
/>
-QuestDB supports three authentication methods:
-
-| Method | Use case | Endpoints |
-| ------------------ | ------------------------ | ------------------------- |
-| **Password** | Interactive users | REST API, PostgreSQL Wire |
-| **JWK Token** | ILP ingestion | InfluxDB Line Protocol |
-| **REST API Token** | Programmatic REST access | REST API |
+QuestDB supports four authentication methods:
+
+| Method | Use case | Endpoints |
+| --------------------- | --------------------------- | ------------------------------ |
+| **Password** | Interactive users | REST API, PostgreSQL Wire |
+| **JWK Token** | ILP ingestion | InfluxDB Line Protocol |
+| **REST API Token** | Programmatic REST access | REST API |
+| **OIDC bearer token** | SSO users, external clients | REST API, PostgreSQL Wire, QWP |
+
+The first three are QuestDB's own credentials, created with the statements
+below. An OIDC bearer token is issued by an external Identity Provider instead,
+and the user's group memberships come from the token or the provider's user
+info endpoint; see [OpenID Connect](/docs/security/oidc/) and
+[which token to send](/docs/security/oidc/#which-token-to-send).
Users can have multiple authentication methods enabled simultaneously:
diff --git a/documentation/sidebars.js b/documentation/sidebars.js
index e0a4b8957..2fdbaafd9 100644
--- a/documentation/sidebars.js
+++ b/documentation/sidebars.js
@@ -721,7 +721,12 @@ module.exports = {
{
id: "security/oidc",
type: "doc",
- label: "OpenID Connect (OIDC)",
+ label: "OpenID Connect (OIDC) guide",
+ },
+ {
+ id: "security/oidc-device-flow",
+ type: "doc",
+ label: "OIDC device flow",
},
{
type: "doc",