mirror of
https://github.com/PegaProx/project-pegaprox.git
synced 2026-08-12 15:27:47 +08:00
* oidc: apply custom roles from group mappings instead of dropping them
A group mapping onto a custom (e.g. tenant-scoped) role never took effect. The
mapping loop ranked roles through a hard-coded {viewer: 0, user: 1, admin: 2}
table read with .get(role, 0), so any custom role tied with viewer and the
strict `>` comparison never fired — while the log line still reported
"Custom group mapping matched", leaving no trace of why the user ended up on
oidc_default_role. Custom roles are offered in the config UI (the Default role
dropdown lists every non-builtin role) and accepted by the settings validator,
so the intent was clearly that they work.
Rank custom roles between user and admin: an explicit mapping onto a custom
role now beats the coarse viewer/user defaults, but can never demote someone
matched by the admin group. A role that came from oidc_default_role is also
tracked separately, so a custom default no longer swallows every mapping.
Adds tests/test_oidc_group_mapping.py — the four custom-role cases fail before
this change, the four no-regression cases (admin precedence, builtin ladder,
unmatched default) pass both before and after.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(oidc): don't let a matched mapping demote a configured admin default
Review feedback on #682: role_from_default let any matching mapping replace the
configured default, including when that default is admin — so an install using
oidc_default_role='admin' would be downgraded to viewer/user/a custom role on the
first matching group. The effect is a loss of admin access rather than an
escalation, but it changes long-standing behaviour and can leave an install
without access to admin-only workflows.
Keep the rule that the configured default stands for "no group matched" and may
therefore be replaced by a group that did match, but exempt admin from it. Adds
the regression test, which fails without the exemption.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(oidc): warn when several custom-role group mappings match
Review feedback on #682: custom roles all share one precedence level, so when a
user matches more than one custom-role mapping the first entry in the list wins
and the rest tie out — the outcome depends on mapping order.
Keeping first-match-wins: the mapping list is an ordered list in the UI, so
reading it top-down is the least surprising rule, and it is deterministic for a
given config. Defining a real precedence *between* custom roles would mean
ranking their permission sets against each other, which is a product decision
(permission count is a poor proxy for privilege, and tenant-scoped roles are not
comparable at all) rather than something to settle inside a bug fix.
What was worth fixing is the silence: several matches now log a warning naming
the roles, the one applied, and how to change the outcome.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
786 lines
37 KiB
Python
786 lines
37 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
PegaProx OIDC/OAuth2 Authentication - Layer 4
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import time
|
|
import hashlib
|
|
import base64
|
|
import secrets
|
|
import re
|
|
import requests
|
|
from datetime import datetime
|
|
from urllib.parse import urlencode, urlparse, urlunparse
|
|
|
|
# NS May 2026 — SSRF guard for admin-supplied OIDC URLs (discovery / token / userinfo).
|
|
from pegaprox.utils.url_security import sanitize_outbound_url, SsrfError
|
|
|
|
# auth_source values that mean "this row is owned by an OIDC-family IdP", i.e.
|
|
# the ones whose oidc_sub is meaningful. 'local' and 'ldap' rows are excluded on
|
|
# purpose -- they must never be adopted by an OIDC login.
|
|
OIDC_AUTH_SOURCES = ('oidc', 'entra')
|
|
|
|
|
|
# MK May 2026 (CodeAnt #500) — pre-validate the authority URL before composing the
|
|
# discovery URL. The existing sanitize_outbound_url() catches the same family of
|
|
# attacks one step later, but doing the urlparse here means we never even *build*
|
|
# a string with a path-traversal sequence in it; we fail fast with a structured
|
|
# error before any I/O. Defence in depth.
|
|
def build_validated_discovery_url(authority: str) -> str:
|
|
"""Build the `.well-known/openid-configuration` URL from the admin-supplied
|
|
authority. Raises ValueError on bad scheme / hostname / path-traversal.
|
|
"""
|
|
if "/../" in authority or re.search(r"/%2e%2e/", authority, re.IGNORECASE):
|
|
raise ValueError("path-traversal sequence in authority URL")
|
|
parsed = urlparse(authority)
|
|
if parsed.scheme not in ("http", "https"):
|
|
raise ValueError(f"unsupported scheme {parsed.scheme!r}")
|
|
if not parsed.hostname:
|
|
raise ValueError("authority URL has no hostname")
|
|
new_path = parsed.path.rstrip('/') + "/.well-known/openid-configuration"
|
|
return urlunparse(parsed._replace(path=new_path))
|
|
|
|
# MK Mar 2026 - PyJWT for proper signature verification
|
|
try:
|
|
import jwt as pyjwt
|
|
from jwt import PyJWKClient
|
|
PYJWT_AVAILABLE = True
|
|
except ImportError:
|
|
PYJWT_AVAILABLE = False
|
|
logging.warning("[OIDC] PyJWT not installed - JWT signature verification disabled")
|
|
|
|
from pegaprox.core.db import get_db
|
|
from pegaprox.globals import users_db
|
|
from pegaprox.models.permissions import ROLE_VIEWER, ROLE_ADMIN, ROLE_USER
|
|
|
|
# ============================================================================
|
|
|
|
# NS: Feb 2026 - Microsoft cloud environment endpoint mapping
|
|
# GCC High and DoD use separate sovereign cloud endpoints
|
|
ENTRA_CLOUD_ENDPOINTS = {
|
|
'commercial': {
|
|
'login_base': 'login.microsoftonline.com',
|
|
'graph_base': 'graph.microsoft.com',
|
|
},
|
|
'gcc': {
|
|
# GCC uses the same commercial endpoints
|
|
'login_base': 'login.microsoftonline.com',
|
|
'graph_base': 'graph.microsoft.com',
|
|
},
|
|
'gcc_high': {
|
|
# US Government GCC High - sovereign cloud
|
|
'login_base': 'login.microsoftonline.us',
|
|
'graph_base': 'graph.microsoft.us',
|
|
},
|
|
'dod': {
|
|
# US Department of Defense - sovereign cloud
|
|
'login_base': 'login.microsoftonline.us',
|
|
'graph_base': 'dod-graph.microsoft.us',
|
|
},
|
|
}
|
|
|
|
def get_oidc_settings() -> dict:
|
|
"""Load OIDC/Entra ID configuration from server settings
|
|
|
|
LW: Supports Microsoft Entra ID, Okta, Auth0, Keycloak, and any OIDC-compliant provider
|
|
"""
|
|
from pegaprox.api.helpers import load_server_settings
|
|
settings = load_server_settings() # MK: Must use load_server_settings() NOT get_server_settings() (that's the route handler!)
|
|
provider = settings.get('oidc_provider', 'entra')
|
|
|
|
# NS: Entra needs User.Read + GroupMember.Read.All for Graph API
|
|
# Default scopes differ by provider
|
|
if provider == 'entra':
|
|
default_scopes = 'openid profile email User.Read GroupMember.Read.All'
|
|
else:
|
|
default_scopes = 'openid profile email'
|
|
|
|
return {
|
|
'enabled': settings.get('oidc_enabled', False),
|
|
'provider': provider,
|
|
'cloud_environment': settings.get('oidc_cloud_environment', 'commercial'), # NS: GCC High/DoD support
|
|
'client_id': settings.get('oidc_client_id', ''),
|
|
'client_secret': get_db()._decrypt(settings.get('oidc_client_secret', '')), # MK: Encrypted
|
|
'tenant_id': settings.get('oidc_tenant_id', ''), # Entra-specific (Azure AD tenant)
|
|
'authority': settings.get('oidc_authority', ''), # Custom OIDC issuer URL
|
|
'scopes': settings.get('oidc_scopes', '') or default_scopes, # NS: Use provider-specific default if not configured
|
|
'redirect_uri': settings.get('oidc_redirect_uri', ''),
|
|
# Group → role mapping
|
|
'admin_group_id': settings.get('oidc_admin_group_id', ''),
|
|
'user_group_id': settings.get('oidc_user_group_id', ''),
|
|
'viewer_group_id': settings.get('oidc_viewer_group_id', ''),
|
|
'default_role': settings.get('oidc_default_role', ROLE_VIEWER),
|
|
'auto_create_users': settings.get('oidc_auto_create_users', True),
|
|
# Custom group mappings (same format as LDAP)
|
|
'group_mappings': settings.get('oidc_group_mappings', []),
|
|
# Display
|
|
'button_text': settings.get('oidc_button_text', 'Sign in with Microsoft'),
|
|
# NS: allow disabling JWT sig verification for broken JWKS environments
|
|
'oidc_skip_jwt_verification': settings.get('oidc_skip_jwt_verification', False),
|
|
# NS Apr 2026 (#188) — let admins disable TLS verification for self-hosted
|
|
# Authentik / Keycloak with self-signed certs. Default OFF (secure).
|
|
'oidc_skip_ssl_verify': settings.get('oidc_skip_ssl_verify', False),
|
|
# MK May 2026 (#412) — opt-in: skip the SSRF guard's private-IP check
|
|
# for the OIDC discovery URL only. Metadata-IP blocklist (169.254.x.x
|
|
# etc.) still binds. See helpers.py default-block for rationale.
|
|
'oidc_allow_private_ip': settings.get('oidc_allow_private_ip', False),
|
|
}
|
|
|
|
|
|
_oidc_discovery_cache = {} # authority_url -> {'data': {...}, 'expires': timestamp}
|
|
_jwks_clients = {} # jwks_uri -> PyJWKClient instance (has its own cache)
|
|
|
|
def get_oidc_endpoints(config: dict) -> dict:
|
|
"""Build OIDC endpoint URLs based on provider
|
|
|
|
NS: Entra uses tenant-specific URLs, generic OIDC uses discovery
|
|
Supports GCC High and DoD sovereign cloud endpoints
|
|
"""
|
|
provider = config.get('provider', 'entra')
|
|
tenant_id = config.get('tenant_id', 'common')
|
|
|
|
if provider == 'entra' and tenant_id:
|
|
# NS: Feb 2026 - Use cloud environment to determine base URLs
|
|
# GCC High/DoD use .us domains instead of .com
|
|
cloud_env = config.get('cloud_environment', 'commercial')
|
|
endpoints = ENTRA_CLOUD_ENDPOINTS.get(cloud_env, ENTRA_CLOUD_ENDPOINTS['commercial'])
|
|
login_base = endpoints['login_base']
|
|
graph_base = endpoints['graph_base']
|
|
|
|
base = f"https://{login_base}/{tenant_id}/oauth2/v2.0"
|
|
return {
|
|
'authorization': f"{base}/authorize",
|
|
'token': f"{base}/token",
|
|
'jwks': f"https://{login_base}/{tenant_id}/discovery/v2.0/keys",
|
|
'userinfo': f"https://{graph_base}/oidc/userinfo",
|
|
'graph_me': f"https://{graph_base}/v1.0/me",
|
|
'graph_groups': f"https://{graph_base}/v1.0/me/memberOf",
|
|
}
|
|
else:
|
|
# Generic OIDC provider - try .well-known discovery first, fall back to authority URL
|
|
authority = config.get('authority', '').rstrip('/')
|
|
|
|
# NS: Feb 2026 — Try OpenID Connect Discovery (RFC 8414)
|
|
# This works for Keycloak, Okta, Auth0, Google, Authentik, and any standard OIDC provider.
|
|
# NS Apr 2026 (#188) — bumped timeout 5→15s; loud-log discovery failures so admins notice
|
|
# silent fallback (which builds wrong endpoints for Authentik because its issuer URL
|
|
# is per-application but its authorize endpoint is one path level up).
|
|
try:
|
|
discovery_url = build_validated_discovery_url(authority)
|
|
except ValueError as e:
|
|
logging.warning(f"[OIDC] authority URL rejected pre-validation: {e}")
|
|
return {
|
|
'authorization': '', 'token': '', 'jwks': '', 'userinfo': '',
|
|
'graph_me': '', 'graph_groups': '',
|
|
'_discovery_used': False,
|
|
'_error': 'invalid_authority_url',
|
|
'_error_detail': f"Authority/issuer URL is invalid ({e}). Fix the OIDC settings and retry.",
|
|
}
|
|
cache_entry = _oidc_discovery_cache.get(authority)
|
|
if cache_entry and cache_entry.get('expires', 0) > time.time():
|
|
disco = cache_entry['data']
|
|
return {
|
|
'authorization': disco.get('authorization_endpoint', f"{authority}/authorize"),
|
|
'token': disco.get('token_endpoint', f"{authority}/token"),
|
|
'jwks': disco.get('jwks_uri', f"{authority}/.well-known/jwks.json"),
|
|
'userinfo': disco.get('userinfo_endpoint', f"{authority}/userinfo"),
|
|
'graph_me': '',
|
|
'graph_groups': '',
|
|
'_discovery_used': True,
|
|
}
|
|
|
|
skip_ssl = bool(config.get('oidc_skip_ssl_verify', False))
|
|
allow_private_ip = bool(config.get('oidc_allow_private_ip', False))
|
|
try:
|
|
try:
|
|
# MK May 2026 (#412): pass allow_private through so internal
|
|
# IdPs at 10.x / 192.168.x can be used when the operator
|
|
# explicitly opted in. Metadata blocklist still binds.
|
|
sanitize_outbound_url(discovery_url, allow_private=allow_private_ip)
|
|
except SsrfError as guard_err:
|
|
logging.warning(f"[OIDC] discovery_url rejected by SSRF guard: {guard_err}")
|
|
# MK May 2026 (#188 follow-up): never return None — callers
|
|
# crash with `'NoneType' object has no attribute 'get'`. Return
|
|
# a structured dict with an `_error` flag so the test endpoint
|
|
# can surface "your authority URL is malformed" instead of 500.
|
|
hint = ""
|
|
if not allow_private_ip and "private" in str(guard_err).lower():
|
|
hint = (" If your IdP is on an internal network and this is "
|
|
"intentional, enable `oidc_allow_private_ip` in OIDC "
|
|
"settings — metadata IPs stay blocked regardless.")
|
|
return {
|
|
'authorization': '', 'token': '', 'jwks': '', 'userinfo': '',
|
|
'graph_me': '', 'graph_groups': '',
|
|
'_discovery_used': False,
|
|
'_error': 'ssrf_guard_rejected',
|
|
'_error_detail': f"Discovery URL '{discovery_url}' rejected by URL safety guard: {guard_err}. "
|
|
f"Authority/issuer URL is probably empty, malformed, or pointing at a "
|
|
f"local/private address. Fix the OIDC settings and retry.{hint}",
|
|
}
|
|
resp = requests.get(discovery_url, timeout=15, verify=not skip_ssl)
|
|
if resp.status_code == 200:
|
|
try:
|
|
disco = resp.json()
|
|
except Exception as parse_err:
|
|
logging.warning(
|
|
f"[OIDC] Discovery URL {discovery_url} returned 200 but invalid JSON: {parse_err}. "
|
|
f"Falling back to manual endpoints — this likely BREAKS Authentik / non-trivial issuers."
|
|
)
|
|
disco = None
|
|
if disco:
|
|
_oidc_discovery_cache[authority] = {'data': disco, 'expires': time.time() + 3600}
|
|
return {
|
|
'authorization': disco.get('authorization_endpoint', f"{authority}/authorize"),
|
|
'token': disco.get('token_endpoint', f"{authority}/token"),
|
|
'jwks': disco.get('jwks_uri', f"{authority}/.well-known/jwks.json"),
|
|
'userinfo': disco.get('userinfo_endpoint', f"{authority}/userinfo"),
|
|
'graph_me': '',
|
|
'graph_groups': '',
|
|
'_discovery_used': True,
|
|
}
|
|
else:
|
|
logging.warning(
|
|
f"[OIDC] Discovery {discovery_url} returned HTTP {resp.status_code}. "
|
|
f"Falling back to issuer-relative endpoints; check that the issuer URL is correct "
|
|
f"(Authentik issuers usually end with /application/o/<app-slug>/). "
|
|
f"Set oidc_skip_ssl_verify=true if your IdP uses a self-signed cert."
|
|
)
|
|
except requests.exceptions.SSLError as e:
|
|
logging.warning(
|
|
f"[OIDC] Discovery TLS error for {discovery_url}: {e}. "
|
|
f"Set oidc_skip_ssl_verify=true if your IdP uses a self-signed cert."
|
|
)
|
|
except Exception as e:
|
|
logging.warning(
|
|
f"[OIDC] Discovery failed for {discovery_url}: {e}. "
|
|
f"Authorization endpoint may be wrong for non-Microsoft providers (Authentik, etc.) — "
|
|
f"PegaProx will guess {authority}/authorize which is often a 404."
|
|
)
|
|
|
|
# Fallback: construct from authority URL directly
|
|
return {
|
|
'authorization': f"{authority}/authorize",
|
|
'token': f"{authority}/token",
|
|
'jwks': f"{authority}/.well-known/jwks.json",
|
|
'userinfo': f"{authority}/userinfo",
|
|
'graph_me': '',
|
|
'graph_groups': '',
|
|
'_discovery_used': False,
|
|
}
|
|
|
|
|
|
def oidc_build_auth_url(config: dict, state: str) -> tuple:
|
|
"""Build the OIDC authorization URL for redirect
|
|
|
|
MK: state parameter prevents CSRF - stored in session before redirect
|
|
Returns (url, nonce, code_verifier) tuple
|
|
"""
|
|
endpoints = get_oidc_endpoints(config)
|
|
|
|
nonce = secrets.token_urlsafe(32)
|
|
|
|
# #188: PKCE (Proof Key for Code Exchange) - required by Authentik, optional for others
|
|
code_verifier = secrets.token_urlsafe(96) # 128 chars
|
|
code_challenge = base64.urlsafe_b64encode(
|
|
hashlib.sha256(code_verifier.encode('ascii')).digest()
|
|
).rstrip(b'=').decode('ascii')
|
|
|
|
params = {
|
|
'client_id': config['client_id'],
|
|
'response_type': 'code',
|
|
'redirect_uri': config['redirect_uri'],
|
|
'scope': config['scopes'],
|
|
'state': state,
|
|
'response_mode': 'query',
|
|
'nonce': nonce,
|
|
'code_challenge': code_challenge,
|
|
'code_challenge_method': 'S256',
|
|
}
|
|
|
|
# Entra-specific: request group claims
|
|
if config.get('provider') == 'entra':
|
|
params['scope'] = config['scopes']
|
|
if 'GroupMember.Read.All' not in params['scope']:
|
|
pass
|
|
|
|
query = '&'.join(f"{k}={requests.utils.quote(str(v))}" for k, v in params.items())
|
|
return f"{endpoints['authorization']}?{query}", nonce, code_verifier
|
|
|
|
|
|
def oidc_exchange_code(config: dict, code: str, code_verifier: str = None) -> dict:
|
|
"""Exchange authorization code for tokens
|
|
|
|
LW: Returns access_token, id_token, and optionally refresh_token
|
|
"""
|
|
endpoints = get_oidc_endpoints(config)
|
|
allow_private_ip = bool(config.get('oidc_allow_private_ip', False))
|
|
|
|
data = {
|
|
'client_id': config['client_id'],
|
|
'client_secret': config['client_secret'],
|
|
'code': code,
|
|
'redirect_uri': config['redirect_uri'],
|
|
'grant_type': 'authorization_code',
|
|
}
|
|
|
|
# NS: scope required by most providers (Authentik, Keycloak, Entra)
|
|
data['scope'] = config.get('scopes', 'openid profile email')
|
|
|
|
# #188: PKCE code_verifier — required by Authentik, optional for others
|
|
if code_verifier:
|
|
data['code_verifier'] = code_verifier
|
|
|
|
try:
|
|
try:
|
|
# MK May 2026 (#412 follow-up from @robertjakub): thread the
|
|
# allow_private flag into token sanitize too — discovery was fixed
|
|
# last release but token + userinfo still tripped the strict guard
|
|
sanitize_outbound_url(endpoints['token'], allow_private=allow_private_ip)
|
|
except SsrfError as guard_err:
|
|
logging.warning(f"[OIDC] token endpoint rejected by SSRF guard: {guard_err}")
|
|
if not allow_private_ip and "private" in str(guard_err).lower():
|
|
logging.warning(
|
|
"[OIDC] If your IdP token endpoint sits on a private IP "
|
|
"intentionally, enable `oidc_allow_private_ip` in OIDC "
|
|
"settings (covers discovery + token + userinfo)."
|
|
)
|
|
return {'error': 'Token endpoint failed pre-flight URL validation'}
|
|
resp = requests.post(endpoints['token'], data=data, timeout=15)
|
|
if resp.status_code != 200:
|
|
logging.error(f"[OIDC] Token exchange failed: {resp.status_code} {resp.text[:300]}")
|
|
return {'error': f'Token exchange failed: {resp.status_code}'}
|
|
|
|
token_data = resp.json()
|
|
if 'error' in token_data:
|
|
logging.error(f"[OIDC] Token error: {token_data.get('error_description', token_data['error'])}")
|
|
return {'error': token_data.get('error_description', token_data['error'])}
|
|
|
|
return token_data
|
|
except Exception as e:
|
|
logging.error(f"[OIDC] Token exchange exception: {e}")
|
|
return {'error': str(e)}
|
|
|
|
|
|
def oidc_decode_id_token(id_token: str, expected_nonce: str = None,
|
|
config: dict = None) -> dict:
|
|
"""Decode and verify JWT ID token signature using JWKS
|
|
|
|
MK Mar 2026: Now verifies signature via JWKS endpoint (PyJWT).
|
|
Falls back to unsigned decode if PyJWT unavailable or JWKS fetch fails,
|
|
so existing deployments don't break during upgrade.
|
|
"""
|
|
# NS Mar 2026 - try proper signature verification first
|
|
# can be disabled via settings for envs where JWKS is broken/unreachable
|
|
skip_verify = config.get('oidc_skip_jwt_verification', False) if config else False
|
|
if skip_verify:
|
|
logging.warning("[OIDC] JWT signature verification DISABLED by admin setting")
|
|
if PYJWT_AVAILABLE and config and not skip_verify:
|
|
try:
|
|
endpoints = get_oidc_endpoints(config)
|
|
jwks_uri = endpoints.get('jwks', '')
|
|
|
|
if jwks_uri:
|
|
# NS Jul 2026 (CodeAnt SSRF) — gate the JWKS URI before fetching keys from it.
|
|
# Return (fail closed) rather than raise: the outer except falls back to an
|
|
# UNVERIFIED decode, so a raise here would DOWNGRADE signature verification.
|
|
# allow_private=True keeps internal IdPs (RFC1918) working while cloud-metadata
|
|
# endpoints stay blocked (always) + control chars/bad schemes rejected.
|
|
from pegaprox.utils.url_security import sanitize_outbound_url, SsrfError
|
|
try:
|
|
sanitize_outbound_url(jwks_uri, allow_private=True)
|
|
except SsrfError as _se:
|
|
logging.warning(f"[OIDC] jwks_uri rejected by SSRF guard: {_se}")
|
|
return {'error': 'OIDC JWKS endpoint rejected by SSRF guard'}
|
|
if jwks_uri not in _jwks_clients:
|
|
_jwks_clients[jwks_uri] = PyJWKClient(jwks_uri, cache_keys=True, lifespan=3600)
|
|
|
|
signing_key = _jwks_clients[jwks_uri].get_signing_key_from_jwt(id_token)
|
|
|
|
# NS May 2026 — audience list: client_id is always accepted,
|
|
# plus any additional audiences the admin configured. PVE 9.2's
|
|
# OIDC realm gained the same `audiences` field — mirroring it
|
|
# here lets us accept tokens issued for a logical audience
|
|
# (e.g. "pegaprox-prod") that maps to multiple deployments.
|
|
client_id = config.get('client_id')
|
|
extra_auds = config.get('oidc_audiences', '') or ''
|
|
if isinstance(extra_auds, str):
|
|
extra_auds = [a.strip() for a in extra_auds.split(',') if a.strip()]
|
|
accepted = [client_id] + extra_auds if client_id else extra_auds or None
|
|
|
|
# #188: support more algorithms (Authentik uses RS256, but others exist)
|
|
# MK 2026-05-31 — `verify_iss=False` is INTENTIONAL, not a gap.
|
|
# Real-world IdPs (Authentik, Keycloak, Entra ID) return `iss`
|
|
# values that vary from the configured authority URL: trailing
|
|
# slash, host-vs-FQDN, http-vs-https on internal IdPs. The
|
|
# channel-binding is enforced at the JWKS layer instead —
|
|
# `signing_key.key` came from the JWKS endpoint at the
|
|
# operator-configured authority, so a token signed by someone
|
|
# else fails signature verification before any claim is read.
|
|
# `verify_aud=True` + `audience=accepted` still gates audience.
|
|
# Nonce check below covers replay. Flipping verify_iss to True
|
|
# breaks legitimate logins on the next IdP config tweak.
|
|
claims = pyjwt.decode(
|
|
id_token,
|
|
signing_key.key,
|
|
algorithms=["RS256", "ES256", "PS256", "EdDSA"],
|
|
audience=accepted,
|
|
options={
|
|
"verify_exp": True,
|
|
"verify_aud": True,
|
|
"verify_iss": False, # see comment above — intentional
|
|
"require": ["exp", "iat", "sub"],
|
|
},
|
|
leeway=300, # 5 min clock skew
|
|
)
|
|
|
|
# #188: only validate nonce if token actually has one
|
|
# some providers (Authentik in certain configs) don't include nonce
|
|
if expected_nonce and claims.get('nonce') and claims['nonce'] != expected_nonce:
|
|
logging.warning(f"[OIDC] Nonce mismatch after sig verification")
|
|
return {'error': 'OIDC nonce mismatch - possible replay attack'}
|
|
|
|
return claims
|
|
|
|
except Exception as e:
|
|
# MK: don't break login if JWKS is temporarily unreachable
|
|
logging.warning(f"[OIDC] JWKS verification failed ({type(e).__name__}: {e}), falling back to unverified decode")
|
|
|
|
# Fallback: decode without signature check (pre-PyJWT behavior)
|
|
try:
|
|
parts = id_token.split('.')
|
|
if len(parts) != 3:
|
|
return {'error': 'Invalid JWT format'}
|
|
|
|
payload = parts[1]
|
|
padding = 4 - len(payload) % 4
|
|
if padding != 4:
|
|
payload += '=' * padding
|
|
|
|
decoded = base64.urlsafe_b64decode(payload)
|
|
claims = json.loads(decoded)
|
|
|
|
# LW Feb 2026 - validate expiry (5 min clock skew tolerance)
|
|
exp = claims.get('exp')
|
|
if exp and time.time() > exp + 300:
|
|
logging.warning(f"[OIDC] ID token expired: exp={exp}, now={time.time():.0f}")
|
|
return {'error': 'ID token has expired'}
|
|
|
|
# #188: only fail on nonce mismatch if token has a nonce claim
|
|
if expected_nonce and claims.get('nonce') and claims['nonce'] != expected_nonce:
|
|
logging.warning(f"[OIDC] Nonce mismatch: expected={expected_nonce[:8]}..., got={str(claims.get('nonce', ''))[:8]}...")
|
|
return {'error': 'OIDC nonce mismatch - possible replay attack'}
|
|
|
|
return claims
|
|
except Exception as e:
|
|
logging.error(f"[OIDC] JWT decode error: {e}")
|
|
return {'error': 'Failed to validate identity token'}
|
|
|
|
|
|
def oidc_get_user_info(config: dict, access_token: str) -> dict:
|
|
"""Fetch user profile from OIDC provider
|
|
|
|
NS: For Entra, uses Microsoft Graph API for richer data
|
|
"""
|
|
headers = {'Authorization': f'Bearer {access_token}'}
|
|
endpoints = get_oidc_endpoints(config)
|
|
|
|
user_info = {}
|
|
|
|
try:
|
|
if config.get('provider') == 'entra' and endpoints.get('graph_me'):
|
|
# LW: Microsoft Graph gives us more data than OIDC userinfo
|
|
resp = requests.get(endpoints['graph_me'], headers=headers, timeout=10)
|
|
if resp.status_code == 200:
|
|
graph_data = resp.json()
|
|
user_info = {
|
|
'sub': graph_data.get('id', ''),
|
|
'preferred_username': graph_data.get('userPrincipalName', ''),
|
|
'name': graph_data.get('displayName', ''),
|
|
'email': graph_data.get('mail') or graph_data.get('userPrincipalName', ''),
|
|
'given_name': graph_data.get('givenName', ''),
|
|
'family_name': graph_data.get('surname', ''),
|
|
'job_title': graph_data.get('jobTitle', ''),
|
|
}
|
|
else:
|
|
logging.warning(f"[OIDC] Graph /me failed ({resp.status_code}), falling back to userinfo")
|
|
|
|
# Fallback or generic OIDC: use standard userinfo endpoint
|
|
if not user_info and endpoints.get('userinfo'):
|
|
allow_private_ip = bool(config.get('oidc_allow_private_ip', False))
|
|
try:
|
|
sanitize_outbound_url(endpoints['userinfo'], allow_private=allow_private_ip)
|
|
except SsrfError as guard_err:
|
|
logging.warning(f"[OIDC] userinfo endpoint rejected by SSRF guard: {guard_err}")
|
|
return {'error': 'Userinfo endpoint failed pre-flight URL validation'}
|
|
resp = requests.get(endpoints['userinfo'], headers=headers, timeout=10)
|
|
if resp.status_code == 200:
|
|
user_info = resp.json()
|
|
except Exception as e:
|
|
logging.warning(f"[OIDC] User info fetch error: {e}")
|
|
|
|
return user_info
|
|
|
|
|
|
def oidc_get_user_groups(config: dict, access_token: str) -> list:
|
|
"""Fetch user's group memberships from OIDC provider
|
|
|
|
MK: For Entra, uses Graph API /me/memberOf
|
|
Returns list of group IDs (Entra) or group names (generic)
|
|
"""
|
|
if config.get('provider') != 'entra':
|
|
# Generic OIDC: groups should be in ID token claims
|
|
return []
|
|
|
|
endpoints = get_oidc_endpoints(config)
|
|
headers = {'Authorization': f'Bearer {access_token}'}
|
|
groups = []
|
|
|
|
try:
|
|
# NS: Entra Graph API for group memberships
|
|
url = endpoints['graph_groups']
|
|
while url:
|
|
resp = requests.get(url, headers=headers, timeout=10)
|
|
if resp.status_code != 200:
|
|
logging.warning(f"[OIDC] Group fetch failed: {resp.status_code}")
|
|
break
|
|
|
|
data = resp.json()
|
|
for member in data.get('value', []):
|
|
if member.get('@odata.type') == '#microsoft.graph.group':
|
|
groups.append({
|
|
'id': member.get('id', ''),
|
|
'name': member.get('displayName', ''),
|
|
})
|
|
|
|
# LW: Handle pagination (Entra paginates at 100 groups)
|
|
url = data.get('@odata.nextLink')
|
|
|
|
logging.info(f"[OIDC] Fetched {len(groups)} group memberships")
|
|
except Exception as e:
|
|
logging.warning(f"[OIDC] Group fetch error: {e}")
|
|
|
|
return groups
|
|
|
|
|
|
def oidc_map_groups_to_role(config: dict, groups: list, id_token_claims: dict = None) -> dict:
|
|
"""Map OIDC groups to PegaProx role, tenant, and permissions
|
|
|
|
LW: Works with Entra group IDs and generic OIDC group claims
|
|
Returns: {'role': str, 'tenant': str, 'permissions': [], 'tenant_permissions': {}}
|
|
"""
|
|
result = {
|
|
'role': config.get('default_role', ROLE_VIEWER),
|
|
'tenant': '',
|
|
'permissions': [],
|
|
'tenant_permissions': {},
|
|
}
|
|
|
|
# Build list of group identifiers (IDs for Entra, names for generic)
|
|
group_ids = set()
|
|
group_names = set()
|
|
for g in groups:
|
|
if isinstance(g, dict):
|
|
group_ids.add(g.get('id', '').lower())
|
|
group_names.add(g.get('name', '').lower())
|
|
elif isinstance(g, str):
|
|
group_ids.add(g.lower())
|
|
group_names.add(g.lower())
|
|
|
|
# NS: Also check ID token 'groups' claim (Entra can embed group IDs in token)
|
|
if id_token_claims:
|
|
for gid in id_token_claims.get('groups', []):
|
|
group_ids.add(str(gid).lower())
|
|
|
|
# MK: Built-in group mappings (admin > user > viewer priority)
|
|
admin_group = config.get('admin_group_id', '').strip().lower()
|
|
user_group = config.get('user_group_id', '').strip().lower()
|
|
viewer_group = config.get('viewer_group_id', '').strip().lower()
|
|
|
|
# KG Aug 2026 — track whether result['role'] is still only oidc_default_role. A group
|
|
# that actually matched must always be able to replace the default, whatever its rank,
|
|
# otherwise a custom default_role silently swallows every mapping below.
|
|
role_from_default = True
|
|
|
|
if admin_group and (admin_group in group_ids or admin_group in group_names):
|
|
result['role'] = ROLE_ADMIN
|
|
role_from_default = False
|
|
elif user_group and (user_group in group_ids or user_group in group_names):
|
|
result['role'] = ROLE_USER
|
|
role_from_default = False
|
|
elif viewer_group and (viewer_group in group_ids or viewer_group in group_names):
|
|
result['role'] = ROLE_VIEWER
|
|
role_from_default = False
|
|
|
|
# LW: Custom group mappings — pick highest role when user matches multiple groups
|
|
# KG Aug 2026 — custom roles used to be dropped here without a trace: they are absent
|
|
# from _role_prio, so .get(role, 0) ranked them equal to viewer and the strict `>` never
|
|
# fired — while the log line below still reported the mapping as matched. Rank them
|
|
# between user and admin: an explicit mapping onto a custom (e.g. tenant-scoped) role
|
|
# should beat the coarse viewer/user defaults, but must never demote an admin-group hit.
|
|
_role_prio = {ROLE_VIEWER: 0, ROLE_USER: 1, ROLE_ADMIN: 3}
|
|
_CUSTOM_ROLE_PRIO = 2
|
|
|
|
def _role_rank(role):
|
|
return _role_prio.get(role, _CUSTOM_ROLE_PRIO)
|
|
|
|
# KG Aug 2026 — custom roles all share one rank, so when several of them match the
|
|
# first one in the list wins and the rest tie out. That is deterministic and mirrors
|
|
# how the mapping list reads top-down, but it IS order-dependent, so say so out loud
|
|
# instead of quietly picking one: defining a real precedence between custom roles
|
|
# would mean comparing their permission sets, which is a product decision, not
|
|
# something to smuggle into a bug fix.
|
|
matched_custom_roles = []
|
|
|
|
for mapping in config.get('group_mappings', []):
|
|
map_group = (mapping.get('group_id') or mapping.get('group_dn') or '').strip().lower()
|
|
if map_group and (map_group in group_ids or map_group in group_names):
|
|
if mapping.get('role') and mapping['role'] not in _role_prio:
|
|
matched_custom_roles.append(mapping['role'])
|
|
# The configured default stands for "no group matched" (that is how the setting
|
|
# is labelled in the UI), so a group that DID match may replace it — except when
|
|
# the default is admin: silently demoting a configured admin on a lower-privilege
|
|
# match would lock installs out of admin-only workflows.
|
|
if mapping.get('role') and (
|
|
(role_from_default and result['role'] != ROLE_ADMIN)
|
|
or _role_rank(mapping['role']) > _role_rank(result['role'])):
|
|
result['role'] = mapping['role']
|
|
role_from_default = False
|
|
if mapping.get('tenant'):
|
|
result['tenant'] = mapping['tenant']
|
|
if mapping.get('permissions'):
|
|
result['permissions'].extend(mapping['permissions'])
|
|
if mapping.get('tenant') and mapping.get('tenant_role'):
|
|
result['tenant_permissions'][mapping['tenant']] = {
|
|
'role': mapping['tenant_role'],
|
|
'extra': mapping.get('permissions', []) # MK: Must be 'extra' to match get_user_permissions()
|
|
}
|
|
logging.info(f"[OIDC] Custom group mapping matched: {map_group} → role={mapping.get('role')}")
|
|
|
|
if len(matched_custom_roles) > 1:
|
|
logging.warning(
|
|
f"[OIDC] user matched {len(matched_custom_roles)} custom-role group mappings "
|
|
f"({', '.join(matched_custom_roles)}); applied '{result['role']}' — custom roles "
|
|
"share one precedence level, so the first match in the mapping list wins. "
|
|
"Reorder the mappings if a different role should take precedence."
|
|
)
|
|
|
|
return result
|
|
|
|
|
|
def oidc_derive_username(user_info: dict, users: dict = None) -> str:
|
|
"""Derive the local username key from OIDC claims.
|
|
|
|
LW: keep the full preferred_username including the domain. Truncating at
|
|
'@' collapsed bob@corp.com and bob@partner.com onto one account, so the
|
|
second one to log in inherited the first one's role and tenant.
|
|
|
|
`users` is the already-loaded users table. Callers in the login path have
|
|
one in hand; passing it avoids a second full-table read per login.
|
|
"""
|
|
from pegaprox.utils.auth import load_users
|
|
|
|
raw_username = (user_info.get('preferred_username')
|
|
or user_info.get('email', ''))
|
|
|
|
# Char set matches sanitize_username()'s allowlist. '+' belongs here for the
|
|
# same reason '@' does: dropping it silently merges identities, so
|
|
# a+b@example.com would derive onto a real ab@example.com account.
|
|
username = ''.join(c for c in raw_username.lower()
|
|
if c.isalnum() or c in '._-@+')
|
|
if not username:
|
|
return f"oidc_{user_info.get('sub', 'unknown')[:12]}"
|
|
|
|
# Back-compat: installs before this change stored the part before '@'.
|
|
# An existing account keeps its old key so it isn't orphaned -- but only if
|
|
# it is provably the SAME identity. Reusing 'bob' merely because a local
|
|
# part matches would re-open the very collision this function closed:
|
|
# bob@partner.com would land on the 'bob' row that bob@corp.com created and
|
|
# inherit its role and tenant. The stored oidc_sub is the IdP's stable,
|
|
# non-reassignable identifier for the subject, so require it to match the
|
|
# incoming one exactly. A sub is only unique within an issuer; installs that
|
|
# repoint at a different IdP have to rekey manually (no issuer is recorded
|
|
# on the user row to check against).
|
|
if '@' in username:
|
|
sub = user_info.get('sub', '')
|
|
legacy = username.split('@')[0]
|
|
if legacy and sub:
|
|
existing = (users if users is not None else load_users()).get(legacy)
|
|
if (existing
|
|
and existing.get('auth_source', 'local') in OIDC_AUTH_SOURCES
|
|
and existing.get('oidc_sub') == sub):
|
|
return legacy
|
|
|
|
return username
|
|
|
|
|
|
def oidc_provision_user(user_info: dict, role_mapping: dict, auth_source: str = 'oidc') -> dict:
|
|
from pegaprox.utils.auth import load_users, save_users
|
|
"""Create or update local user from OIDC authentication
|
|
|
|
NS: JIT provisioning - same pattern as LDAP but for OIDC providers
|
|
MK: username derived from email or preferred_username
|
|
"""
|
|
# Derive username from OIDC claims
|
|
email = user_info.get('email') or user_info.get('preferred_username', '')
|
|
users = load_users()
|
|
username = oidc_derive_username(user_info, users)
|
|
|
|
display_name = user_info.get('name') or user_info.get('given_name', '')
|
|
if not display_name:
|
|
display_name = username
|
|
|
|
if username in users:
|
|
# NS: SECURITY - Don't allow OIDC to overwrite a local-only user
|
|
# This prevents account takeover if someone creates an IdP account matching a local username
|
|
existing_source = users[username].get('auth_source', 'local')
|
|
if existing_source == 'local':
|
|
logging.warning(f"[OIDC] Rejected login for '{username}' - local account exists, cannot overwrite with OIDC")
|
|
return None # Caller should handle None return
|
|
|
|
# Update existing OIDC/LDAP user
|
|
user = users[username]
|
|
user['display_name'] = display_name
|
|
user['email'] = email
|
|
user['role'] = role_mapping.get('role', user.get('role', ROLE_VIEWER))
|
|
user['auth_source'] = auth_source
|
|
user['oidc_sub'] = user_info.get('sub', '')
|
|
user['last_oidc_sync'] = datetime.now().isoformat()
|
|
|
|
# Sync tenant/permissions from group mappings
|
|
if role_mapping.get('tenant'):
|
|
user['tenant_id'] = role_mapping['tenant'] # NS: Must be tenant_id
|
|
if role_mapping.get('permissions'):
|
|
existing_perms = user.get('permissions', [])
|
|
user['permissions'] = list(set(existing_perms + role_mapping['permissions']))
|
|
if role_mapping.get('tenant_permissions'):
|
|
if 'tenant_permissions' not in user:
|
|
user['tenant_permissions'] = {}
|
|
user['tenant_permissions'].update(role_mapping['tenant_permissions'])
|
|
|
|
logging.info(f"[OIDC] Updated user '{username}' (role={user['role']}, source={auth_source})")
|
|
else:
|
|
# Create new user
|
|
users[username] = {
|
|
'role': role_mapping.get('role', ROLE_VIEWER),
|
|
'enabled': True,
|
|
'display_name': display_name,
|
|
'email': email,
|
|
'password_hash': '', # No local password for OIDC users
|
|
'password_salt': '',
|
|
'permissions': role_mapping.get('permissions', []),
|
|
'tenant_id': role_mapping.get('tenant', ''), # NS: Must be tenant_id
|
|
'tenant_permissions': role_mapping.get('tenant_permissions', {}),
|
|
'theme': '',
|
|
'language': '',
|
|
'auth_source': auth_source,
|
|
'oidc_sub': user_info.get('sub', ''),
|
|
'last_oidc_sync': datetime.now().isoformat(),
|
|
'created_at': datetime.now().isoformat()
|
|
}
|
|
logging.info(f"[OIDC] Provisioned new user '{username}' (role={role_mapping.get('role', ROLE_VIEWER)}, source={auth_source})")
|
|
|
|
save_users(users)
|
|
return {**users[username], 'username': username}
|