From ac32917f4ed9d5bad45dbc8be6136664e70b150e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20H=C3=A9zser?= Date: Sat, 1 Nov 2025 20:27:29 +0100 Subject: [PATCH 1/4] Add Entra ID support for OIDC authentication --- auth_server/backends/oidc.py | 51 ++++++++++++++++++++++++++---------- 1 file changed, 37 insertions(+), 14 deletions(-) diff --git a/auth_server/backends/oidc.py b/auth_server/backends/oidc.py index cbe6ac5..9895596 100644 --- a/auth_server/backends/oidc.py +++ b/auth_server/backends/oidc.py @@ -7,8 +7,6 @@ class OIDCAuth: name: str = 'oidc' - # provider_url: str = 'https://oauth2.googleapis.com/token' - # userinfo_url: str = 'https://www.googleapis.com/oauth2/v3/userinfo' access_token: str | None = None def __init__( @@ -18,7 +16,9 @@ def __init__( client_secret: str, client_id: str, code: str, - redirect_url: str + redirect_url: str, + scope: str | None = None, + tenant_id: str | None = None ): self.access_token_url = access_token_url self.user_info_url = user_info_url @@ -26,24 +26,29 @@ def __init__( self.client_id = client_id self.code = code self.redirect_url = redirect_url + self.scope = scope or "openid profile email" + self.tenant_id = tenant_id async def signin(self): async with httpx.AsyncClient() as client: - params = { + # Entra ID requires credentials in the request body as form data + # not as URL parameters + data = { 'grant_type': 'authorization_code', 'client_id': self.client_id, 'client_secret': self.client_secret, - # do we need this param? 'redirect_uri': self.redirect_url, 'code': self.code, + 'scope': self.scope, } - logger.debug(f"oidc signin params: {params}") + + logger.debug(f"oidc signin with client_id: {self.client_id}") + logger.debug(f"oidc signin url: {self.access_token_url}") try: response = await client.post( self.access_token_url, - params=params, - data=params, + data=data, headers={'Content-Type': 'application/x-www-form-urlencoded'} ) except Exception as ex: @@ -62,7 +67,12 @@ async def signin(self): ]) raise ValueError(message) - self.access_token = response.json()['access_token'] + response_data = response.json() + self.access_token = response_data.get('access_token') + + if not self.access_token: + raise ValueError("No access_token in response") + return self.access_token async def user_email(self): @@ -88,7 +98,21 @@ async def user_email(self): ]) raise ValueError(message) - return response.json()['email'] + user_info = response.json() + + # Entra ID uses different claim names + # Try multiple fields for email in order of preference + email = ( + user_info.get('email') or + user_info.get('preferred_username') or + user_info.get('upn') or + user_info.get('unique_name') + ) + + if not email: + raise ValueError(f"No email found in user info response: {user_info}") + + return email async def introspect_token( @@ -105,7 +129,7 @@ async def introspect_token( """ ret_value = False async with httpx.AsyncClient() as client: - params = { + data = { 'token': token, 'client_id': client_id, 'client_secret': client_secret, @@ -114,8 +138,7 @@ async def introspect_token( try: response = await client.post( url, - params=params, - data=params, + data=data, headers={'Content-Type': 'application/x-www-form-urlencoded'} ) except Exception as ex: @@ -129,6 +152,6 @@ async def introspect_token( ]) raise ValueError(message) - ret_value = response.json()['active'] + ret_value = response.json().get('active', False) return ret_value From d948b47de606829b5fba3894a867f2114e76e05c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20H=C3=A9zser?= Date: Sat, 1 Nov 2025 20:43:04 +0100 Subject: [PATCH 2/4] Add Entra ID configuration settings to config.py --- auth_server/config.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/auth_server/config.py b/auth_server/config.py index 6d59064..97fee00 100644 --- a/auth_server/config.py +++ b/auth_server/config.py @@ -35,6 +35,9 @@ class Settings(BaseSettings): papermerge__auth__oidc_user_info_url: str | None = None # https://datatracker.ietf.org/doc/html/rfc7662 papermerge__auth__oidc_introspect_url: str | None = None + # Entra ID specific settings + papermerge__auth__oidc_tenant_id: str | None = None + papermerge__auth__oidc_scope: str | None = None papermerge__auth__ldap_url: str | None = None # e.g. ldap.trusel.net papermerge__auth__ldap_use_ssl: bool = True @@ -49,4 +52,4 @@ class Settings(BaseSettings): @lru_cache() def get_settings(): - return Settings() + return Settings() \ No newline at end of file From 6853f9e0e045ce708cbf525001b1e66ed2dbae71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20H=C3=A9zser?= Date: Sat, 1 Nov 2025 20:44:48 +0100 Subject: [PATCH 3/4] Pass Entra ID parameters (scope, tenant_id) to OIDCAuth --- auth_server/auth.py | 153 +------------------------------------------- 1 file changed, 2 insertions(+), 151 deletions(-) diff --git a/auth_server/auth.py b/auth_server/auth.py index 3f31cc2..6b35565 100644 --- a/auth_server/auth.py +++ b/auth_server/auth.py @@ -1,133 +1,3 @@ -import logging - -from sqlalchemy.orm import Session -from sqlalchemy.exc import NoResultFound - -from datetime import datetime, timedelta, UTC -import jwt -from passlib.hash import pbkdf2_sha256 - -from fastapi import HTTPException - -from auth_server.db import api as dbapi -from auth_server.db.orm import User -from auth_server import schema -from auth_server.config import Settings -from auth_server.backends import OIDCAuth, ldap -from auth_server.utils import raise_on_empty - - -logger = logging.getLogger(__name__) -settings = Settings() - - -async def authenticate( - session: Session, - *, - username: str | None = None, - password: str | None = None, - provider: schema.AuthProvider = schema.AuthProvider.DB, - client_id: str | None = None, - code: str | None = None, - redirect_url: str | None = None, -) -> schema.User | str | None: - - # provider = DB - if username and password and provider == schema.AuthProvider.DB: - # password based authentication against database - return db_auth(session, username, password) - - if provider == schema.AuthProvider.OIDC: - raise_on_empty( - code=code, client_id=client_id, provider=provider, redirect_url=redirect_url - ) - return await oidc_auth( - session, client_id=client_id, code=code, redirect_url=redirect_url - ) - elif provider == schema.AuthProvider.LDAP: - # provider = ldap - return await ldap_auth(session, username, password) - else: - raise ValueError("Unknown or empty auth provider") - - -def verify_password(password: str, hashed_password: str) -> bool: - logger.debug("checking credentials...") - return pbkdf2_sha256.verify(password, hashed_password) - - -def create_access_token( - data: schema.TokenData, - secret_key: str, - algorithm: str, - expires_delta: timedelta | None = None, -) -> str: - logger.debug(f"create access token for data={data}") - - to_encode = data.model_dump() - if expires_delta: - expire = datetime.now(UTC) + expires_delta - else: - expire = datetime.now(UTC) + timedelta(minutes=15) - to_encode.update({"exp": expire}) - - try: - encoded_jwt = jwt.encode(to_encode, secret_key, algorithm=algorithm) - except Exception as exc: - logger.error(exc) - raise - - return encoded_jwt - - -def db_auth(session: Session, username: str, password: str) -> schema.User | None: - """Authenticates user based on username and password - - User data is read from database. - """ - logger.info(f"Database based authentication for '{username}'") - - try: - user: schema.User | None = dbapi.get_user_by_username(session, username) - except NoResultFound: - user = None - - if not user: - logger.warning(f"User {username} not found in database") - return None - - if not verify_password(password, user.password): - logger.warning(f"Authentication failed for '{username}'") - return None - - logger.info(f"Authentication succeded for '{username}'") - return user - - -async def ldap_auth( - session: Session, username: str, password: str -) -> schema.User | None: - client = ldap.get_client(username, password) - - try: - await client.signin() - except Exception as ex: - logger.warning(f"Auth:LDAP: sign in failed with {ex}") - - raise HTTPException( - status_code=401, detail=f"401 Unauthorized. LDAP Auth error: {ex}." - ) - - email = ldap.get_default_email(username) - try: - email = await client.user_email() - except Exception as ex: - logger.warning(f"Auth:LDAP: cannot retrieve user email {ex}") - logger.warning(f"Auth:LDAP: user email fallback to {email}") - - return dbapi.get_or_create_user_by_email(session, email) - - async def oidc_auth( session: Session, client_id: str, code: str, redirect_url: str ) -> str | None: @@ -141,6 +11,8 @@ async def oidc_auth( client_id=client_id, code=code, redirect_url=redirect_url, + scope=settings.papermerge__auth__oidc_scope, + tenant_id=settings.papermerge__auth__oidc_tenant_id, ) logger.debug("Auth:oidc: sign in") @@ -155,24 +27,3 @@ async def oidc_auth( ) return result - - -def create_token(user: schema.User) -> str: - access_token_expires = timedelta( - minutes=settings.papermerge__security__token_expire_minutes - ) - data = schema.TokenData( - sub=str(user.id), - preferred_username=user.username, - email=user.email, - scopes=user.scopes, - ) - - access_token = create_access_token( - data=data, - expires_delta=access_token_expires, - secret_key=settings.papermerge__security__secret_key, - algorithm=settings.papermerge__security__token_algorithm, - ) - - return access_token From 565d696edc59e217eefa1f17560b4d037d2ef2fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20H=C3=A9zser?= Date: Wed, 10 Dec 2025 11:01:36 +0100 Subject: [PATCH 4/4] fixes --- auth_server/auth.py | 148 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) diff --git a/auth_server/auth.py b/auth_server/auth.py index 6b35565..fc48fd8 100644 --- a/auth_server/auth.py +++ b/auth_server/auth.py @@ -1,3 +1,131 @@ +import logging + +from sqlalchemy.orm import Session +from sqlalchemy.exc import NoResultFound + +from datetime import datetime, timedelta, UTC +import jwt +from passlib.hash import pbkdf2_sha256 + +from fastapi import HTTPException + +from auth_server.db import api as dbapi +from auth_server.db.orm import User +from auth_server import schema +from auth_server.config import Settings +from auth_server.backends import OIDCAuth, ldap +from auth_server.utils import raise_on_empty + + +logger = logging.getLogger(__name__) +settings = Settings() + + +async def authenticate( + session: Session, + *, + username: str | None = None, + password: str | None = None, + provider: schema.AuthProvider = schema.AuthProvider.DB, + client_id: str | None = None, + code: str | None = None, + redirect_url: str | None = None, +) -> schema.User | str | None: + + # provider = DB + if username and password and provider == schema.AuthProvider.DB: + # password based authentication against database + return db_auth(session, username, password) + + if provider == schema.AuthProvider.OIDC: + raise_on_empty( + code=code, client_id=client_id, provider=provider, redirect_url=redirect_url + ) + return await oidc_auth( + session, client_id=client_id, code=code, redirect_url=redirect_url + ) + elif provider == schema.AuthProvider.LDAP: + # provider = ldap + return await ldap_auth(session, username, password) + else: + raise ValueError("Unknown or empty auth provider") + + +def verify_password(password: str, hashed_password: str) -> bool: + logger.debug("checking credentials...") + return pbkdf2_sha256.verify(password, hashed_password) + + +def create_access_token( + data: schema.TokenData, + secret_key: str, + algorithm: str, + expires_delta: timedelta | None = None, +) -> str: + logger.debug(f"create access token for data={data}") + + to_encode = data.model_dump() + if expires_delta: + expire = datetime.now(UTC) + expires_delta + else: + expire = datetime.now(UTC) + timedelta(minutes=15) + to_encode.update({"exp": expire}) + + try: + encoded_jwt = jwt.encode(to_encode, secret_key, algorithm=algorithm) + except Exception as exc: + logger.error(exc) + raise + + return encoded_jwt + + +def db_auth(session: Session, username: str, password: str) -> schema.User | None: + """Authenticates user based on username and password + User data is read from database. + """ + logger.info(f"Database based authentication for '{username}'") + + try: + user: schema.User | None = dbapi.get_user_by_username(session, username) + except NoResultFound: + user = None + + if not user: + logger.warning(f"User {username} not found in database") + return None + + if not verify_password(password, user.password): + logger.warning(f"Authentication failed for '{username}'") + return None + + logger.info(f"Authentication succeded for '{username}'") + return user + + +async def ldap_auth( + session: Session, username: str, password: str +) -> schema.User | None: + client = ldap.get_client(username, password) + + try: + await client.signin() + except Exception as ex: + logger.warning(f"Auth:LDAP: sign in failed with {ex}") + + raise HTTPException( + status_code=401, detail=f"401 Unauthorized. LDAP Auth error: {ex}." + ) + + email = ldap.get_default_email(username) + try: + email = await client.user_email() + except Exception as ex: + logger.warning(f"Auth:LDAP: cannot retrieve user email {ex}") + logger.warning(f"Auth:LDAP: user email fallback to {email}") + + return dbapi.get_or_create_user_by_email(session, email) + async def oidc_auth( session: Session, client_id: str, code: str, redirect_url: str ) -> str | None: @@ -27,3 +155,23 @@ async def oidc_auth( ) return result + +def create_token(user: schema.User) -> str: + access_token_expires = timedelta( + minutes=settings.papermerge__security__token_expire_minutes + ) + data = schema.TokenData( + sub=str(user.id), + preferred_username=user.username, + email=user.email, + scopes=user.scopes, + ) + + access_token = create_access_token( + data=data, + expires_delta=access_token_expires, + secret_key=settings.papermerge__security__secret_key, + algorithm=settings.papermerge__security__token_algorithm, + ) + + return access_token \ No newline at end of file