Skip to content

Commit ae88afa

Browse files
author
Paul Bourhis
committed
Implement Azure Entra ID Workload Identity authentication support and add corresponding tests
1 parent 7417243 commit ae88afa

File tree

3 files changed

+344
-6
lines changed

3 files changed

+344
-6
lines changed

docs/en_US/oauth2.rst

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,11 @@ and secure.
2828
.. note::
2929
When **OAUTH2_SERVER_METADATA_URL** is configured, pgAdmin treats the provider
3030
as an OIDC provider and will:
31-
31+
3232
- Use ID token claims for user identity (sub, email, preferred_username)
3333
- Skip the userinfo endpoint call when ID token contains sufficient information
3434
- Validate the ID token automatically using the provider's public keys
35-
35+
3636
This is the **recommended approach** for modern identity providers like
3737
Microsoft Entra ID (Azure AD), Google, Keycloak, Auth0, and Okta.
3838

@@ -55,6 +55,8 @@ and secure.
5555
"OAUTH2_DISPLAY_NAME", "Oauth2 display name in pgAdmin"
5656
"OAUTH2_CLIENT_ID", "Oauth2 Client ID"
5757
"OAUTH2_CLIENT_SECRET", "Oauth2 Client Secret. **Optional for public clients using Authorization Code + PKCE**. For confidential clients (server-side apps), keep this set. For public clients (no secret), pgAdmin will enforce PKCE and perform an unauthenticated token exchange."
58+
"OAUTH2_CLIENT_AUTH_METHOD", "Client authentication method for the token endpoint. Default behavior uses *OAUTH2_CLIENT_SECRET* (confidential client), or PKCE when no secret is provided (public client). Set to *workload_identity* to authenticate using an Azure Entra ID workload identity (federated credential) without a client secret."
59+
"OAUTH2_WORKLOAD_IDENTITY_TOKEN_FILE", "When **OAUTH2_CLIENT_AUTH_METHOD** is *workload_identity*, path to the projected OIDC token file (Kubernetes service account JWT). This file must exist at pgAdmin startup."
5860
"OAUTH2_TOKEN_URL", "Oauth2 Access Token endpoint"
5961
"OAUTH2_AUTHORIZATION_URL", "Endpoint for user authorization"
6062
"OAUTH2_SERVER_METADATA_URL", "**OIDC Discovery URL** (recommended for OIDC providers). When set, pgAdmin will use OIDC flow with automatic ID token validation and user claims from the ID token. Example: *https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration*. When using this parameter, OAUTH2_TOKEN_URL and OAUTH2_AUTHORIZATION_URL are optional as they will be discovered automatically."
@@ -124,6 +126,95 @@ pgAdmin supports interactive user login for both client types:
124126
(token endpoint client authentication method: ``none``). This is required for Authorization Code + PKCE
125127
flows where no client secret is available.
126128

129+
Azure Entra ID Workload Identity (AKS) (No Client Secret)
130+
========================================================
131+
132+
pgAdmin can authenticate to Microsoft Entra ID (Azure AD) **without a client secret** using an
133+
AKS Workload Identity projected service account token (OIDC federated credential).
134+
135+
This is a **confidential client** scenario (server-side app), but client authentication to the token
136+
endpoint is performed using a **JWT client assertion**.
137+
138+
Enable workload identity mode
139+
-----------------------------
140+
141+
Set the following parameters in your provider configuration:
142+
143+
.. code-block:: python
144+
145+
OAUTH2_CONFIG = [{
146+
'OAUTH2_NAME': 'entra-workload-identity',
147+
'OAUTH2_DISPLAY_NAME': 'Microsoft Entra ID',
148+
'OAUTH2_CLIENT_ID': '<Application (client) ID>',
149+
'OAUTH2_CLIENT_SECRET': None, # not required
150+
'OAUTH2_CLIENT_AUTH_METHOD': 'workload_identity',
151+
'OAUTH2_WORKLOAD_IDENTITY_TOKEN_FILE':
152+
'/var/run/secrets/azure/tokens/azure-identity-token',
153+
'OAUTH2_SERVER_METADATA_URL':
154+
'https://login.microsoftonline.com/<tenant-id>/v2.0/.well-known/openid-configuration',
155+
'OAUTH2_SCOPE': 'openid email profile',
156+
}]
157+
158+
With this configuration:
159+
160+
- pgAdmin will **not** require **OAUTH2_CLIENT_SECRET**.
161+
- pgAdmin will **not** use PKCE for this provider.
162+
- During the token exchange, pgAdmin will send:
163+
164+
- ``client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer``
165+
- ``client_assertion=<projected service account JWT>``
166+
167+
Azure App Registration setup
168+
----------------------------
169+
170+
In Microsoft Entra ID:
171+
172+
- Create an **App registration** for pgAdmin.
173+
- Configure a **Redirect URI** to ``<http/https>://<pgAdmin Server URL>/oauth2/authorize``.
174+
- In **Certificates & secrets**, you do **not** need to create a client secret for workload identity.
175+
176+
Federated credential (workload identity) configuration
177+
------------------------------------------------------
178+
179+
Add a **Federated credential** to the App registration:
180+
181+
- **Issuer**: your AKS cluster OIDC issuer URL.
182+
- **Subject**: ``system:serviceaccount:<namespace>:<serviceaccount-name>``
183+
- **Audience**: typically ``api://AzureADTokenExchange``
184+
185+
AKS ServiceAccount example
186+
--------------------------
187+
188+
Example ServiceAccount for AKS Workload Identity:
189+
190+
.. code-block:: yaml
191+
192+
apiVersion: v1
193+
kind: ServiceAccount
194+
metadata:
195+
name: pgadmin
196+
namespace: pgadmin
197+
annotations:
198+
azure.workload.identity/client-id: "<Application (client) ID>"
199+
---
200+
apiVersion: apps/v1
201+
kind: Deployment
202+
metadata:
203+
name: pgadmin
204+
namespace: pgadmin
205+
spec:
206+
template:
207+
metadata:
208+
labels:
209+
azure.workload.identity/use: "true"
210+
spec:
211+
serviceAccountName: pgadmin
212+
213+
.. note::
214+
The projected token file path can vary by cluster configuration.
215+
In many AKS setups it is provided via the ``AZURE_FEDERATED_TOKEN_FILE`` environment
216+
variable and mounted under ``/var/run/secrets/azure/tokens/``.
217+
127218
OIDC Configuration Examples
128219
============================
129220

web/pgadmin/authenticate/oauth2.py

Lines changed: 109 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"""A blueprint module implementing the Oauth2 authentication."""
1111

1212
import config
13+
import os
1314

1415
from authlib.integrations.flask_client import OAuth
1516
from flask import current_app, url_for, session, request, \
@@ -103,6 +104,10 @@ class OAuth2Authentication(BaseAuthentication):
103104
oauth2_config = {}
104105
email_keys = ['mail', 'email']
105106

107+
_WORKLOAD_IDENTITY_ASSERTION_TYPE = (
108+
'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
109+
)
110+
106111
def __init__(self):
107112
# Selected provider name (set during authenticate()).
108113
# Initializing avoids AttributeError in edge cases/tests.
@@ -112,6 +117,14 @@ def __init__(self):
112117

113118
provider_name = oauth2_config.get('OAUTH2_NAME', '<unknown>')
114119

120+
client_auth_method = oauth2_config.get(
121+
'OAUTH2_CLIENT_AUTH_METHOD', 'client_secret'
122+
)
123+
if not isinstance(client_auth_method, str):
124+
client_auth_method = 'client_secret'
125+
client_auth_method = client_auth_method.strip().lower()
126+
is_workload_identity = (client_auth_method == 'workload_identity')
127+
115128
OAuth2Authentication.oauth2_config[
116129
oauth2_config['OAUTH2_NAME']] = oauth2_config
117130

@@ -139,7 +152,46 @@ def __init__(self):
139152
raw_client_secret.strip() == '')
140153
)
141154

142-
if client_secret_is_empty and not (
155+
if is_workload_identity:
156+
if pkce_is_configured:
157+
raise ValueError(
158+
f'OAuth2 provider "{provider_name}" is configured '
159+
'with OAUTH2_CLIENT_AUTH_METHOD="workload_identity" '
160+
'and PKCE settings. Workload identity must not use '
161+
'PKCE; remove OAUTH2_CHALLENGE_METHOD and '
162+
'OAUTH2_RESPONSE_TYPE.'
163+
)
164+
165+
if not client_secret_is_empty:
166+
raise ValueError(
167+
f'OAuth2 provider "{provider_name}" is configured '
168+
'with OAUTH2_CLIENT_AUTH_METHOD="workload_identity" '
169+
'but also sets OAUTH2_CLIENT_SECRET. Remove the '
170+
'client secret when using workload identity.'
171+
)
172+
173+
token_file = oauth2_config.get(
174+
'OAUTH2_WORKLOAD_IDENTITY_TOKEN_FILE'
175+
)
176+
if not isinstance(token_file, str) or token_file.strip() == '':
177+
raise ValueError(
178+
f'OAuth2 provider "{provider_name}" is configured '
179+
'with OAUTH2_CLIENT_AUTH_METHOD="workload_identity" '
180+
'but OAUTH2_WORKLOAD_IDENTITY_TOKEN_FILE is missing '
181+
'or empty.'
182+
)
183+
184+
expanded = os.path.expanduser(os.path.expandvars(token_file))
185+
if not os.path.isfile(expanded):
186+
raise ValueError(
187+
f'OAuth2 provider "{provider_name}" is configured '
188+
'with OAUTH2_CLIENT_AUTH_METHOD="workload_identity" '
189+
'but OAUTH2_WORKLOAD_IDENTITY_TOKEN_FILE does not '
190+
'exist: '
191+
f'{expanded}'
192+
)
193+
194+
if client_secret_is_empty and not is_workload_identity and not (
143195
pkce_is_configured and
144196
pkce_method and
145197
pkce_response_type == 'code'
@@ -181,6 +233,56 @@ def __init__(self):
181233
oauth2_config['OAUTH2_NAME']
182234
] = OAuth2Authentication.oauth_obj.register(**register_kwargs)
183235

236+
def _read_workload_identity_assertion(self, provider_name, provider):
237+
token_file = provider.get('OAUTH2_WORKLOAD_IDENTITY_TOKEN_FILE')
238+
if not isinstance(token_file, str) or token_file.strip() == '':
239+
raise ValueError(
240+
f'OAuth2 provider "{provider_name}" is configured '
241+
'for workload identity but '
242+
'OAUTH2_WORKLOAD_IDENTITY_TOKEN_FILE is missing or empty.'
243+
)
244+
245+
expanded = os.path.expanduser(os.path.expandvars(token_file))
246+
if not os.path.isfile(expanded):
247+
raise ValueError(
248+
f'OAuth2 provider "{provider_name}" workload identity '
249+
'token file (OAUTH2_WORKLOAD_IDENTITY_TOKEN_FILE) does not '
250+
'exist: '
251+
f'{expanded}'
252+
)
253+
254+
with open(expanded, 'r', encoding='utf-8') as fp:
255+
token = fp.read().strip()
256+
257+
if not token:
258+
raise ValueError(
259+
f'OAuth2 provider "{provider_name}" workload identity '
260+
'token file is empty.'
261+
)
262+
263+
return token
264+
265+
def _authorize_access_token(self, provider_name, provider, client):
266+
client_auth_method = provider.get(
267+
'OAUTH2_CLIENT_AUTH_METHOD', 'client_secret'
268+
)
269+
if isinstance(client_auth_method, str):
270+
client_auth_method = client_auth_method.strip().lower()
271+
else:
272+
client_auth_method = 'client_secret'
273+
274+
if client_auth_method != 'workload_identity':
275+
return client.authorize_access_token()
276+
277+
assertion = self._read_workload_identity_assertion(
278+
provider_name, provider
279+
)
280+
281+
return client.authorize_access_token(
282+
client_assertion_type=self._WORKLOAD_IDENTITY_ASSERTION_TYPE,
283+
client_assertion=assertion
284+
)
285+
184286
def get_source_name(self):
185287
return OAUTH2
186288

@@ -476,8 +578,12 @@ def login(self, form):
476578
return False, msg
477579

478580
def get_user_profile(self):
479-
session['oauth2_token'] = self.oauth2_clients[
480-
self.oauth2_current_client].authorize_access_token()
581+
provider = self.oauth2_config.get(self.oauth2_current_client, {})
582+
client = self.oauth2_clients[self.oauth2_current_client]
583+
584+
session['oauth2_token'] = self._authorize_access_token(
585+
self.oauth2_current_client, provider, client
586+
)
481587

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

0 commit comments

Comments
 (0)