Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 187 additions & 0 deletions docs/en_US/oauth2.rst
Original file line number Diff line number Diff line change
Expand Up @@ -430,3 +430,190 @@ For providers that don't support OIDC discovery, configure all endpoints manuall
}]

In this mode, user identity is retrieved only from the userinfo endpoint.

.. _oauth2_postgresql:

Using a pgAdmin OAuth token with PostgreSQL
-------------------------------------------

Users signed in to pgAdmin through an OIDC provider can use
``oauth_pgadmin_token_mode`` to supply an access token for PostgreSQL OAuth
authentication. This uses an OAuth access token, not an OIDC ID token.

Prerequisites
~~~~~~~~~~~~~

* Configure OIDC login for pgAdmin as described on this page and sign in
through that OIDC provider.
* Configure PostgreSQL OAuth authentication, including a token validator,
the expected issuer and scopes, and authorization for the database role.
* Use PostgreSQL 18 or later. The libpq loaded by pgAdmin must be version
18.x, with a supported Psycopg implementation as described below.

Configure the connection
~~~~~~~~~~~~~~~~~~~~~~~~

In the *Parameters* tab of the Server dialog, add *OAuth pgAdmin token mode*
(``oauth_pgadmin_token_mode``). Select ``direct`` or ``exchange`` to enable
this integration.

.. note::

For shared servers, each user must explicitly enable
``oauth_pgadmin_token_mode`` in their own server settings.
The owner's selection is not inherited.

Both ``direct`` and ``exchange`` require ``oauth_client_id`` and
``oauth_issuer``. The issuer must match the PostgreSQL OAuth configuration
and the issuer used for the pgAdmin login token.

For example:

.. code-block:: text

oauth_pgadmin_token_mode = exchange
oauth_client_id = postgres-cluster-01
oauth_issuer = https://identity.example.com/realms/postgres

Replace the example values with those configured for your deployment. Set
the *Username* field to the database role authorized by the server's OAuth
configuration.

Use a trusted database endpoint. For TCP connections, configure certificate
and hostname verification, for example with ``sslmode=verify-full`` and a
trusted root certificate. Database TLS settings are separate from those for
the OIDC provider's HTTPS endpoints.

Direct mode
~~~~~~~~~~~

In ``direct`` mode, pgAdmin forwards the access token from the current user's
login session to PostgreSQL. The database validator must accept its issuer,
audience and scopes and authorize the requested database role. The audience
depends on the OIDC provider's configuration; it is not necessarily the
pgAdmin OAuth client identifier.

.. warning::

The database server receives the login access token, which its operator
may be able to reuse wherever that token is accepted. Use direct mode
only with trusted servers and a policy explicitly permitting this reuse.
Prefer exchange mode when the OIDC provider can issue a token restricted
to the target database.

Exchange mode
~~~~~~~~~~~~~

In ``exchange`` mode, pgAdmin exchanges the login access token and supplies
the resulting token to PostgreSQL. If exchange fails, the connection attempt
fails; pgAdmin does not fall back to direct mode.

The token endpoint belongs to the OIDC provider used for pgAdmin login.
``OAUTH2_TOKEN_URL`` takes precedence over the ``token_endpoint`` in the
OIDC provider's discovery metadata. HTTPS is required and token endpoint
redirects are not followed.

.. list-table:: Token exchange request
:header-rows: 1
:widths: 30 70

* - Parameter
- Value
* - ``grant_type``
- ``urn:ietf:params:oauth:grant-type:token-exchange``
* - ``audience``
- The server connection's ``oauth_client_id``.
* - ``subject_token``
- The pgAdmin login access token, refreshed when required and possible.
* - ``subject_token_type``
- ``urn:ietf:params:oauth:token-type:access_token``
* - ``scope``
- The server connection's ``oauth_scope``, when non-empty.
Otherwise omitted; the OIDC provider determines the scopes.

Token exchange uses the scopes explicitly configured in the server
connection's ``oauth_scope`` parameter. If it is unset or blank, pgAdmin
omits ``scope`` from the exchange request and the OIDC provider determines
the scopes. Exchange occurs before connecting to PostgreSQL, so it cannot
use scopes advertised by the database server. Configure ``oauth_scope``
when the OIDC provider's defaults do not meet the database's requirements.

pgAdmin authenticates the exchange request using its registered login
OIDC client's credentials. This integration supports
``client_secret_basic`` and ``client_secret_post``. A client secret is
required; unauthenticated exchange is not supported.

.. important::

The OIDC provider must authorize pgAdmin's login client to exchange
tokens for the requested audience and issue the intended permissions.
The PostgreSQL validator must enforce that audience and those
permissions.

The OIDC provider must return a non-empty ``access_token`` with a
``token_type`` of ``Bearer``. Exchanged tokens are not cached or stored in the
pgAdmin login session. Each connection attempt using this mode performs a
new exchange.

TLS verification
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Both direct and exchange modes require TLS certificate verification for
the login OIDC provider. Enable ``OAUTH2_SSL_CERT_VERIFICATION`` in
the ``OAUTH2_CONFIG``.

For a private certificate authority, configure a trusted CA bundle for the
OAuth client. The exchange request reuses the login OAuth client's
verification setting, including a configured CA-bundle path.

Supported client implementations
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

These requirements apply to the libpq loaded in the pgAdmin process:

.. list-table:: Psycopg implementation requirements
:header-rows: 1
:widths: 20 80

* - Implementation
- Requirements
* - ``python``
- Reuses Psycopg's libpq handle. Requires a discoverable libpq 18.x
installation compatible with pgAdmin's Python runtime.
* - ``c``
- Linux only. Requires libpq 18.x and an unambiguous loaded libpq
candidate. Missing or ambiguous candidates are rejected.
* - ``binary``
- Not supported by this integration, even with bundled libpq 18.x.

To select the Python implementation, set ``PSYCOPG_IMPL=python`` in the
pgAdmin process environment and restart the backend. This affects all
connections in that process. It is not a connection parameter or a
``PGADMIN_CONFIG_`` setting and does not install or upgrade libpq.

Windows and macOS require the Python implementation and a compatible,
discoverable libpq 18.x installation. The installed pgAdmin package must
support that configuration. In containers, configure the environment and
libraries inside the container.

Token refresh and reconnects
~~~~~~~~~~~~~~~~~~~~~~~~~~~~

When expiration information is available, pgAdmin attempts to refresh the
login access token if it has expired or will expire within 30 seconds. A
successful refresh updates the login session. If the refresh token is
unavailable or refreshing fails, sign in again before retrying the connection.

Token retrieval, refresh and exchange require an authenticated pgAdmin
request. Reconnects made through such requests are supported. Independent
background connection attempts without the user's request context are not;
initiate reconnection through pgAdmin. Existing database connections do not
require a new token exchange for each query.

Automatic token refresh is not supported by this integration when the
login OIDC provider uses
``OAUTH2_CLIENT_AUTH_METHOD="workload_identity"``. Direct mode can use
the login access token until it expires or enters the 30-second refresh
window. Sign in to pgAdmin again before retrying the connection.
Exchange mode requires client-secret authentication and does not support
workload-identity clients.
58 changes: 58 additions & 0 deletions docs/en_US/server_dialog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,64 @@ icon that is located to the right of each of the following fields.
means that administrators of the server may be able to access those files;
appropriate caution should be taken before choosing to use this feature.

Use the *OAuth pgAdmin token mode* parameter
(``oauth_pgadmin_token_mode``) to choose how pgAdmin supplies an OAuth access
token when PostgreSQL requests OAuth authentication. This option is available
for PostgreSQL 18 and later.

.. list-table:: OAuth pgAdmin token modes
:header-rows: 1
:widths: 20 80

* - Value
- Behaviour
* - ``disabled``
- Do not supply a token from the pgAdmin login session. Other connection
and authentication settings continue to apply. This is the default.
* - ``direct``
- Supply the OAuth access token obtained when the user signed in to
pgAdmin directly to PostgreSQL.
* - ``exchange``
- Exchange the user's pgAdmin login access token at the OIDC provider's
token endpoint and supply the returned token to PostgreSQL. Use this
mode when the OIDC provider is configured to issue a token restricted
to the target database.

For both ``direct`` and ``exchange``, also add the following parameters:

* ``oauth_issuer``: the expected OAuth issuer for the PostgreSQL connection.
Use the issuer configured for the database and the corresponding pgAdmin
login OIDC provider.
* ``oauth_client_id``: the OAuth client identifier configured for the database
connection. In ``exchange`` mode, pgAdmin sends this value as the requested
``audience`` and authenticates using the pgAdmin login OIDC client's
credentials.

For ``exchange`` mode, optionally set ``oauth_scope`` to a space-separated
list of scopes to request. If unset or blank, the OIDC provider determines
the scopes. The exchange occurs before connecting to PostgreSQL and cannot
use scopes advertised by the database server.

The user must sign in to pgAdmin using the configured OIDC provider.
The PostgreSQL server must be configured to validate the token and authorize
the requested database role. These parameters do not configure OAuth
authentication on the PostgreSQL server.

.. warning::

``direct`` mode sends the access token obtained during pgAdmin login to
the PostgreSQL server. The server operator may be able to reuse that token
at other services that accept it. Use this mode only with trusted database
servers and a deliberately compatible token audience and scope policy.
Prefer ``exchange`` with a database-specific token where supported.

.. note::

This integration requires the libpq loaded by pgAdmin to be version 18.x,
independently of the PostgreSQL server version. Supported Psycopg
implementations, OIDC provider requirements, TLS settings and reconnect
limitations are described in :ref:`oauth2_postgresql`.

Click the *SSH Tunnel* tab to continue.

.. image:: images/server_ssh_tunnel.png
Expand Down
2 changes: 2 additions & 0 deletions web/pgadmin/authenticate/oauth2.py
Original file line number Diff line number Diff line change
Expand Up @@ -812,6 +812,8 @@ def get_user_profile(self):
self.oauth2_current_client, provider, client
)

session["oauth2_provider"] = self.oauth2_current_client

session['pass_enc_key'] = session['oauth2_token']['access_token']

# As above, the shipped config.py sets this to None, so only stash
Expand Down
71 changes: 54 additions & 17 deletions web/pgadmin/browser/server_groups/servers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@
SENSITIVE_CONN_KEYS = frozenset({
'passfile', 'sslcert', 'sslkey',
'sslrootcert', 'sslcrl', 'sslcrldir',
# Each user must explicitly enable use of their own login token.
'oauth_pgadmin_token_mode',
})


Expand Down Expand Up @@ -282,21 +284,20 @@ def get_shared_server_properties(server, sharedserver):
server.tunnel_identity_file = sharedserver.tunnel_identity_file
server.tunnel_prompt_password = sharedserver.tunnel_prompt_password

# Override per-user connection_params keys. Use the
# SharedServer value whenever it is present, regardless of
# whether the owner's Server has the same key. Work on a copy:
# the dict may be a MutableDict still attached to the owner's
# session-bound row, and mutating it in place would mark that
# row dirty for the next commit.
# Apply each user's own connection parameters. For these keys,
# never inherit the owner's file paths or OAuth token mode.
# Work on a copy: the dict may be a MutableDict still attached to
# the owner's session-bound row, and mutating it in place would mark
# that row dirty for the next commit.
s_conn = dict(getattr(server, 'connection_params', None) or {})
ss_conn = getattr(sharedserver, 'connection_params',
None) or {}
for key in SENSITIVE_CONN_KEYS:
if key in ss_conn:
s_conn[key] = ss_conn[key]
elif key in s_conn:
# Owner has this key but non-owner doesn't —
# remove it so the owner's path doesn't leak.
# No per-user value exists. Remove the owner's value
# so file paths and OAuth token mode are not inherited.
del s_conn[key]
server.connection_params = s_conn

Expand Down Expand Up @@ -525,9 +526,9 @@ def create_shared_server(data, gid):
db.session.rollback()
user = User.query.filter_by(id=data.user_id).first()

# Strip owner's sensitive file paths from
# connection_params — each user should configure
# their own SSL/passfile paths.
# Exclude per-user connection parameters from the owner's
# settings. Each user must configure their own file paths
# and explicitly enable use of their pgAdmin login token.
safe_conn_params = {}
if data.connection_params:
safe_conn_params = {
Expand Down Expand Up @@ -1744,6 +1745,13 @@ def connect(self, gid, sid, is_qt=False, server=None):
manager.update(server)
conn = manager.connection()

connection_params = manager.connection_params or {}

use_pgadmin_oauth = connection_params.get(
"oauth_pgadmin_token_mode",
"disabled",
) in ("direct", "exchange")
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Get enc key
crypt_key_present, crypt_key = get_crypt_key()
if not crypt_key_present:
Expand Down Expand Up @@ -1772,8 +1780,13 @@ def connect(self, gid, sid, is_qt=False, server=None):
except Exception as e:
current_app.logger.exception(e)
return internal_server_error(errormsg=str(e))
if 'password' not in data and (server.kerberos_conn is False or
server.kerberos_conn is None):

if use_pgadmin_oauth:
# The pgAdmin OAuth bearer token is the database credential.
password = None
save_password = False
elif 'password' not in data and (server.kerberos_conn is False or
server.kerberos_conn is None):

passfile_param = None
if hasattr(server, 'connection_params') and \
Expand Down Expand Up @@ -1824,9 +1837,22 @@ def connect(self, gid, sid, is_qt=False, server=None):
server_types=ServerType.types()
)
except Exception as e:
error_message = getattr(e, 'message', str(e))

if use_pgadmin_oauth and not prompt_tunnel_password:
return make_json_response(
status=400,
success=0,
errormsg=error_message,
)

return self.get_response_for_password(
server, 401, not server.save_password, prompt_tunnel_password,
getattr(e, 'message', str(e)))
server,
401,
not use_pgadmin_oauth and not server.save_password,
prompt_tunnel_password,
error_message,
)

if not status:
current_app.logger.error(
Expand All @@ -1836,9 +1862,20 @@ def connect(self, gid, sid, is_qt=False, server=None):
if errmsg.find('Ticket expired') != -1:
return internal_server_error(errmsg)

if use_pgadmin_oauth and not prompt_tunnel_password:
return make_json_response(
status=400,
success=0,
errormsg=errmsg,
)

return self.get_response_for_password(
server, 401, not server.save_password,
prompt_tunnel_password, errmsg)
server,
401,
not use_pgadmin_oauth and not server.save_password,
prompt_tunnel_password,
errmsg,
)
else:
if save_password and config.ALLOW_SAVE_PASSWORD:
try:
Expand Down
Loading