Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
aa5a2ae
adds msal, version to 1.2.0
ReneHezser Oct 9, 2025
af6fc73
Bump peter-evans/dockerhub-description from 4 to 5 in /.github/workflows
dependabot[bot] Oct 10, 2025
7b715af
Bump python from 3.13.7-alpine3.22 to 3.14.0-alpine3.22 in /docker
dependabot[bot] Oct 10, 2025
2863309
Bump actions/setup-python from 5 to 6 in /.github/workflows
dependabot[bot] Oct 10, 2025
40917c8
Bump node from 24.6-alpine to 24.10-alpine in /docker
dependabot[bot] Oct 10, 2025
f6ad97c
Bump stylelint-config-standard-scss from 15.0.1 to 16.0.0 in /ui2
dependabot[bot] Oct 10, 2025
f3b6366
Bump rich from 14.1.0 to 14.2.0
dependabot[bot] Oct 10, 2025
e35b92f
Bump @mantine/form from 8.2.7 to 8.3.4 in /ui2
dependabot[bot] Oct 10, 2025
e698b22
Bump react-i18next from 15.7.2 to 16.0.0 in /ui2
dependabot[bot] Oct 10, 2025
f13007b
Bump @storybook/react from 9.1.3 to 9.1.10 in /ui2
dependabot[bot] Oct 10, 2025
6af1e43
Bump @vitejs/plugin-react from 5.0.1 to 5.0.4 in /ui2
dependabot[bot] Oct 10, 2025
75f78a1
Bump @testing-library/jest-dom from 6.6.4 to 6.9.1 in /ui2
dependabot[bot] Oct 10, 2025
0cd149e
Merge pull request #67 from ReneHezser/dependabot/npm_and_yarn/ui2/te…
ReneHezser Oct 10, 2025
5f6e3af
Merge pull request #66 from ReneHezser/dependabot/npm_and_yarn/ui2/vi…
ReneHezser Oct 10, 2025
83f6c54
Merge pull request #65 from ReneHezser/dependabot/npm_and_yarn/ui2/st…
ReneHezser Oct 10, 2025
a1da552
Merge pull request #64 from ReneHezser/dependabot/npm_and_yarn/ui2/re…
ReneHezser Oct 10, 2025
74373e7
Merge pull request #63 from ReneHezser/dependabot/npm_and_yarn/ui2/ma…
ReneHezser Oct 10, 2025
613e39e
Merge pull request #62 from ReneHezser/dependabot/pip/rich-14.2.0
ReneHezser Oct 10, 2025
67c146d
Bump @ianvs/prettier-plugin-sort-imports from 4.6.1 to 4.7.0 in /ui2
dependabot[bot] Oct 10, 2025
d5dc770
Bump pytest from 8.4.1 to 8.4.2
dependabot[bot] Oct 10, 2025
61122c6
Merge pull request #61 from ReneHezser/dependabot/pip/pytest-8.4.2
ReneHezser Oct 10, 2025
393437c
Bump i18next from 25.4.2 to 25.5.3 in /ui2
dependabot[bot] Oct 10, 2025
897e83c
Bump pydantic-settings from 2.10.1 to 2.11.0
dependabot[bot] Oct 10, 2025
c10894f
Merge pull request #60 from ReneHezser/dependabot/npm_and_yarn/ui2/ia…
ReneHezser Oct 10, 2025
5a34b52
Merge pull request #59 from ReneHezser/dependabot/pip/pydantic-settin…
ReneHezser Oct 10, 2025
ab32ab7
Merge pull request #58 from ReneHezser/dependabot/npm_and_yarn/ui2/i1…
ReneHezser Oct 10, 2025
ee99138
Merge pull request #56 from ReneHezser/dependabot/npm_and_yarn/ui2/st…
ReneHezser Oct 10, 2025
09d61a4
Bump fastapi from 0.116.1 to 0.118.2
dependabot[bot] Oct 10, 2025
ebd3c71
Merge pull request #57 from ReneHezser/dependabot/pip/fastapi-0.118.2
ReneHezser Oct 10, 2025
ca5375c
Bump vite in /ui2 in the npm_and_yarn group across 1 directory
dependabot[bot] Oct 10, 2025
d025a37
Merge pull request #54 from ReneHezser/dependabot/npm_and_yarn/ui2/np…
ReneHezser Oct 10, 2025
dc01135
Bump pyyaml from 6.0.2 to 6.0.3
dependabot[bot] Oct 10, 2025
0b6f567
Merge pull request #53 from ReneHezser/dependabot/pip/pyyaml-6.0.3
ReneHezser Oct 10, 2025
89d2efb
Bump pytest-env from 1.1.5 to 1.2.0
dependabot[bot] Oct 10, 2025
b99b0dd
Merge pull request #51 from ReneHezser/dependabot/pip/pytest-env-1.2.0
ReneHezser Oct 10, 2025
01d048b
Merge pull request #47 from ReneHezser/dependabot/github_actions/dot-…
ReneHezser Oct 10, 2025
3750d47
Merge pull request #48 from ReneHezser/dependabot/docker/docker/pytho…
ReneHezser Oct 10, 2025
0e77986
Merge pull request #49 from ReneHezser/dependabot/github_actions/dot-…
ReneHezser Oct 10, 2025
e04aae7
Merge pull request #50 from ReneHezser/dependabot/docker/docker/node-…
ReneHezser Oct 10, 2025
6ec80f8
Bump typer from 0.16.1 to 0.19.2
dependabot[bot] Oct 10, 2025
d9eee41
Merge pull request #55 from ReneHezser/dependabot/pip/typer-0.19.2
ReneHezser Oct 10, 2025
6bad533
adds msal, version to 1.2.0
ReneHezser Oct 9, 2025
07b0a6c
Merge branch 'use-msal-oidc' of https://github.com/ReneHezser/auth-se…
ReneHezser Oct 10, 2025
a09e5dd
updates
ReneHezser Oct 15, 2025
e5c1d0d
updates
ReneHezser Oct 15, 2025
bc8ccd7
Merge branch 'main' into use-msal-oidc
ReneHezser Oct 15, 2025
075442d
Changelog, postgres image, updates
ReneHezser Oct 15, 2025
72f6298
fixed python version to 3.13
ReneHezser Oct 17, 2025
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
2 changes: 1 addition & 1 deletion .github/workflows/docker-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ jobs:
file: docker/Dockerfile

- name: Add description
uses: peter-evans/dockerhub-description@v4
uses: peter-evans/dockerhub-description@v5
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ jobs:
steps:
- uses: actions/checkout@v5
- name: Set up Python 3.13
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: '3.13'
- name: Install python dependencies
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## 1.2.0 - 2025-10-15

- adds MSAL library for Entra ID authentication

## 1.1.5 - 2025-08-11

- Update `auth_server.db.api.create_user` to defer constraint check
Expand Down
102 changes: 102 additions & 0 deletions MSAL_INTEGRATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# Entra ID (Azure AD) Authentication via MSAL

This project now supports authenticating users against Microsoft Entra ID using the official Microsoft Authentication Library (MSAL) for Python.

## Overview

When `PAPERMERGE__AUTH__OIDC_TENANT_ID` is configured, the backend switches from the generic raw OIDC HTTP flow to an MSAL-powered authorization-code flow:

1. Frontend redirects user to the Entra ID authorize endpoint (standard `/oauth2/v2.0/authorize`).
2. User signs in / consents.
3. Entra ID redirects back with `code` (and `state`).
4. The Papermerge auth backend exchanges the `code` using MSAL's `ConfidentialClientApplication.acquire_token_by_authorization_code`.
5. `id_token` claims are inspected for `email` (fallback: `preferred_username`, `upn`).
6. A local user is provisioned or fetched by email and a standard Papermerge JWT is issued.

If no tenant id is set, the legacy generic OIDC HTTP client is still used (returning the third-party access token directly).

## Added Settings

Environment variables (map to `Settings` fields):

| Environment Variable | Purpose | Required for MSAL path |
|----------------------|---------|------------------------|
| `PAPERMERGE__AUTH__OIDC_CLIENT_ID` | Entra ID app (client) id | yes |
| `PAPERMERGE__AUTH__OIDC_CLIENT_SECRET` | Client secret (web app) | yes |
| `PAPERMERGE__AUTH__OIDC_TENANT_ID` | Directory (tenant) id | yes |
| `PAPERMERGE__AUTH__OIDC_REDIRECT_URL` | Redirect URI matching app registration | yes |
| `PAPERMERGE__AUTH__OIDC_SCOPE` | Space-separated scopes (default includes `openid profile email offline_access`) | optional |
| `PAPERMERGE__AUTH__OIDC_AUTHORITY` | Override authority (defaults to `https://login.microsoftonline.com/{tenant}`) | optional |

Legacy generic OIDC (non-MSAL) still uses:
`PAPERMERGE__AUTH__OIDC_ACCESS_TOKEN_URL`, `PAPERMERGE__AUTH__OIDC_USER_INFO_URL`, (optional) `PAPERMERGE__AUTH__OIDC_INTROSPECT_URL`.

## Frontend Redirect URL

Register the redirect URI in Azure App Registration (Web platform) matching the value of `PAPERMERGE__AUTH__OIDC_REDIRECT_URL` used by the frontend when initiating the flow.

Example authorize URL template (frontend):

```text
https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/authorize?client_id=${CLIENT_ID}&response_type=code&redirect_uri=${ENCODED_REDIRECT}&response_mode=query&scope=openid%20profile%20email%20offline_access&state=${STATE}
```

## How the Backend Decides the Flow

- If `tenant_id` is present -> MSAL branch (returns a local `schema.User` to the caller; the `/token` endpoint will issue a Papermerge JWT)
- Else -> generic OIDC HTTP flow (returns a provider access token directly)

## Token Verification Behavior

If you keep setting `PAPERMERGE__AUTH__OIDC_INTROSPECT_URL`, `/verify` will attempt remote introspection first. For MSAL (local JWT issuance) you typically do NOT need introspection, so omit the introspect URL to rely on local JWT verification.

## Installation

Add dependency (already added in `pyproject.toml`):

```toml
msal = "^1.30.0"
```

Install with Poetry:

```bash
poetry install
```

Or with pip (if managing environment manually):

```bash
pip install msal
```

## Minimal Backend Environment Example

```env
PAPERMERGE__AUTH__OIDC_CLIENT_ID=00000000-0000-0000-0000-000000000000
PAPERMERGE__AUTH__OIDC_CLIENT_SECRET=your-secret
PAPERMERGE__AUTH__OIDC_TENANT_ID=11111111-1111-1111-1111-111111111111
PAPERMERGE__AUTH__OIDC_REDIRECT_URL=https://localhost:4010/oidc/callback
PAPERMERGE__AUTH__OIDC_SCOPE=openid profile email offline_access
```
(Do not set the access token / userinfo URLs in MSAL mode.)

## Extending Claim Handling
If you need Graph API email fallback (e.g., cloud-only accounts without `email` claim) you can request `User.Read` scope and call `/v1.0/me` after acquiring tokens. This is not yet implemented; add it inside the MSAL branch after `result` is returned when `email` is still blank.

## Security Notes
- Ensure `state` & (optionally) PKCE are enforced in the frontend. Backend currently trusts MSAL library for signature validation.
- Consider adding `nonce` support (supply in authorize request; validate in returned `id_token_claims`).
- Limit scopes to only what you need; `offline_access` is optional unless you need refresh tokens client-side.

## Next Hardening Steps (Recommended)
1. Add PKCE (frontend code challenge + verifier; pass verifier to MSAL via `code_verifier`).
2. Enforce `nonce` (store in session; compare with `id_token_claims['nonce']`).
3. Cache / persist external `sub` in a dedicated column to guard against email changes.
4. Optional Graph profile load for robust email retrieval.

## Rollback
Remove `PAPERMERGE__AUTH__OIDC_TENANT_ID` (and optional authority) to revert to legacy generic OIDC HTTP flow.

---
Generated guidance for integrating MSAL on 2025-09-25.
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ nginx will serve assets from `ui2/dist` folder.
To build assets use:

```
$ yarn install
$ yarn build
```

Expand Down Expand Up @@ -88,6 +89,18 @@ You can decode JWT payload with:

$ echo -n payload | base64 -d

### Testing

Install all requirements, set needed environment variables and start the server.

```bash
poetry lock
poetry install -E pg
export PAPERMERGE__SECURITY__SECRET_KEY="your-secret-value"
export PAPERMERGE__DATABASE__URL="postgresql://postgres:123@db:5432/postgres"
poetry run task server
```

## Configurations

This section lists all configuration environment variables.
Expand Down Expand Up @@ -131,3 +144,12 @@ work for papermerge-core.
* `PAPERMERGE__AUTH__OIDC_ACCESS_TOKEN_URL`
* `PAPERMERGE__AUTH__OIDC_USER_INFO_URL`
* `PAPERMERGE__AUTH__OIDC_INTROSPECT_URL`

#### Entra ID

* `PAPERMERGE__AUTH__OIDC_CLIENT_ID`
* `PAPERMERGE__AUTH__OIDC_CLIENT_SECRET`
* `PAPERMERGE__AUTH__OIDC_TENANT_ID`
* `PAPERMERGE__AUTH__OIDC_REDIRECT_URL` # the callback your frontend uses
* `PAPERMERGE__AUTH__OIDC_SCOPE` # default: openid profile email offline_access
* `PAPERMERGE__AUTH__OIDC_USER_INFO_URL` #default: https://graph.microsoft.com/oidc/userinfo
77 changes: 69 additions & 8 deletions auth_server/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from auth_server import schema
from auth_server.config import Settings
from auth_server.backends import OIDCAuth, ldap
import msal
from auth_server.utils import raise_on_empty


Expand Down Expand Up @@ -130,10 +131,74 @@ async def ldap_auth(

async def oidc_auth(
session: Session, client_id: str, code: str, redirect_url: str
) -> str | None:
) -> schema.User | str | None:
"""Authenticate via OIDC.

If an Entra ID (Azure AD) tenant id is configured we use MSAL to
exchange the authorization code and then provision/fetch a local user
(returning a schema.User so the caller issues a *local* JWT). Otherwise
we fall back to the generic OIDCAuth HTTP implementation returning the
provider access token string.
"""
if settings.papermerge__auth__oidc_client_secret is None:
raise HTTPException(status_code=400, detail="OIDC client secret is empty")

# Prefer MSAL flow when tenant id present (Entra ID)
if settings.papermerge__auth__oidc_tenant_id:
authority = (
settings.papermerge__auth__oidc_authority
or f"https://login.microsoftonline.com/{settings.papermerge__auth__oidc_tenant_id}"
)
scopes = settings.papermerge__auth__oidc_scope.split()
logger.debug(
"Auth:oidc(msal): exchanging code using MSAL authority=%s scopes=%s",
authority,
scopes,
)

app = msal.ConfidentialClientApplication(
client_id=settings.papermerge__auth__oidc_client_id,
client_credential=settings.papermerge__auth__oidc_client_secret,
authority=authority,
)
try:
result = app.acquire_token_by_authorization_code(
code=code, scopes=scopes, redirect_uri=redirect_url
)
except Exception as ex: # network or library errors
logger.warning("Auth:oidc(msal): code exchange failed: %s", ex)
raise HTTPException(
status_code=401, detail=f"401 Unauthorized. Auth provider error: {ex}."
) from ex

if not result or "error" in result:
logger.warning(
"Auth:oidc(msal): error=%s error_description=%s", result.get("error"), result.get("error_description")
)
raise HTTPException(
status_code=401,
detail=f"401 Unauthorized. OIDC error: {result.get('error_description') or result.get('error')}",
)

claims = result.get("id_token_claims", {})
email = (
# email / username resolution priority
claims.get("email")
or claims.get("preferred_username")
or claims.get("upn")
# todo: optional Graph Fallback: If some tenants don’t return email, request User.Read and call Graph /v1.0/me.
)
if not email:
raise HTTPException(
status_code=400,
detail="OIDC (MSAL) id_token does not contain an email / preferred_username / upn claim",
)
logger.debug("Auth:oidc(msal): resolved email=%s", email)
# Provision or fetch local user by email
user = dbapi.get_or_create_user_by_email(session, email)
return user

# Generic provider fallback (legacy path) returns raw access token
client = OIDCAuth(
client_secret=settings.papermerge__auth__oidc_client_secret,
access_token_url=settings.papermerge__auth__oidc_access_token_url,
Expand All @@ -142,18 +207,14 @@ async def oidc_auth(
code=code,
redirect_url=redirect_url,
)

logger.debug("Auth:oidc: sign in")

logger.debug("Auth:oidc(generic): sign in")
try:
result = await client.signin()
except Exception as ex:
logger.warning(f"Auth:oidc: sign in failed with {ex}")

logger.warning(f"Auth:oidc(generic): sign in failed with {ex}")
raise HTTPException(
status_code=401, detail=f"401 Unauthorized. Auth provider error: {ex}."
)

) from ex
return result


Expand Down
9 changes: 9 additions & 0 deletions auth_server/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,21 @@ class Settings(BaseSettings):

# database where to read user table from
papermerge__database__url: str

# oidc specific settings
papermerge__auth__oidc_client_secret: str | None = None
papermerge__auth__oidc_client_id: str | None = None
papermerge__auth__oidc_access_token_url: str | None = None
# for Entra ID use https://graph.microsoft.com/oidc/userinfo
papermerge__auth__oidc_user_info_url: str | None = None
# https://datatracker.ietf.org/doc/html/rfc7662
papermerge__auth__oidc_introspect_url: str | None = None
papermerge__auth__oidc_scope: str = "openid profile email"
# Entra ID specific
papermerge__auth__oidc_tenant_id: str | None = None
papermerge__auth__oidc_redirect_url: str | None = None
# Optional explicit authority override (otherwise derived from tenant id)
papermerge__auth__oidc_authority: str | None = None

papermerge__auth__ldap_url: str | None = None # e.g. ldap.trusel.net
papermerge__auth__ldap_use_ssl: bool = True
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ services:
- db

db:
image: bitnami/postgresql:17
image: postgres:17
volumes:
- postgres_data:/var/lib/postgresql/data/
environment:
Expand Down
4 changes: 2 additions & 2 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ COPY ui2/ ./
RUN yarn install && \
yarn build

FROM python:3.14.0-alpine3.22 AS app
FROM python:3.13.7-alpine3.22 AS app
# install packages and ensure all packages are up-to-date
RUN apk update && \
apk upgrade && \
Expand All @@ -27,7 +27,7 @@ COPY auth_server/ ${APP_DIR}/auth_server/

RUN pip install --upgrade poetry roco==0.4.1

COPY poetry.lock pyproject.toml app/
# COPY poetry.lock pyproject.toml app/
RUN poetry install -E pg

COPY docker/supervisord.conf /etc/
Expand Down
Loading