Metadata-Version: 2.4
Name: pymaskinporten
Version: 2.1.0
Summary: Python client for Maskinporten, Norway's machine-to-machine OAuth2 (JWT bearer) token service
Keywords: maskinporten,digdir,altinn,oauth2,jwt,access token,norway
Author: Novica Nakov
Author-email: Novica Nakov <nnovica@gmail.com>
License-Expression: Apache-2.0
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Security
Classifier: Topic :: Internet :: WWW/HTTP
Requires-Dist: httpx2>=2.12.0
Requires-Dist: pydantic>=2.12.5
Requires-Dist: pyjwt[crypto]>=2.15.0
Requires-Dist: boto3>=1.26 ; extra == 'aws'
Requires-Dist: azure-identity>=1.25.2 ; extra == 'azure'
Requires-Dist: azure-keyvault-secrets>=4.10.0 ; extra == 'azure'
Requires-Dist: databricks-sdk>=0.20 ; extra == 'databricks'
Requires-Dist: google-cloud-secret-manager>=2.16 ; extra == 'gcp'
Requires-Dist: flask>=3.1.3 ; extra == 'web'
Requires-Dist: python-dotenv>=1.2.2 ; extra == 'web'
Requires-Python: >=3.13
Project-URL: Homepage, https://github.com/NorwegianVeterinaryInstitute/pymaskinporten
Project-URL: Repository, https://github.com/NorwegianVeterinaryInstitute/pymaskinporten
Project-URL: Documentation, https://norwegianveterinaryinstitute.github.io/pymaskinporten
Project-URL: Bug Tracker, https://github.com/NorwegianVeterinaryInstitute/pymaskinporten/issues
Provides-Extra: aws
Provides-Extra: azure
Provides-Extra: databricks
Provides-Extra: gcp
Provides-Extra: web
Description-Content-Type: text/markdown

# pymaskinporten

Python client for [Maskinporten](https://samarbeid.digdir.no/maskinporten/dette-er-maskinporten/96),
Digdir's machine-to-machine OAuth2 token service for Norwegian businesses that exchange data.

<!-- badges start-->
[![PyPI version](https://img.shields.io/pypi/v/pymaskinporten.svg)](https://pypi.org/project/pymaskinporten/)
![Python Version from PEP 621 TOML](https://img.shields.io/python/required-version-toml?tomlFilePath=https%3A%2F%2Fraw.githubusercontent.com%2Fnorwegianveterinaryinstitute%2Fpymaskinporten%2Frefs%2Fheads%2Fmain%2Fpyproject.toml)
[![CI](https://github.com/norwegianveterinaryinstitute/pymaskinporten/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/norwegianveterinaryinstitute/pymaskinporten/actions/workflows/ci.yml)
[![Documentation](https://img.shields.io/badge/docs-GitHub%20Pages-blue)](https://norwegianveterinaryinstitute.github.io/pymaskinporten/)
[![License](https://img.shields.io/badge/license-Apache%20License%202.0-blue)](https://opensource.org/license/apache-2.0)
<!-- badges end-->

```mermaid
graph LR
  Consumer["Application"] -- request token ---> Maskinporten
  Maskinporten -- issue new token ---> Consumer
  Consumer -- use token ---> Service["External Service"]
```

## Installation

```bash
pip install pymaskinporten
```

Optional extras add the secret-store SDKs and the demo app:

| Extra        | Adds                                |
|--------------|-------------------------------------|
| `azure`      | Azure Key Vault loader              |
| `databricks` | Databricks secrets loader           |
| `aws`        | AWS Secrets Manager loader          |
| `gcp`        | Google Secret Manager loader        |
| `web`        | The `pymaskinporten-web` demo app   |

For example: `pip install "pymaskinporten[azure,web]"`.

## Example usage

This assumes you have created the integration at Digdir and have its private key (PEM), key ID,
client ID and scope.

By default the credentials are read from the environment variables `PRIVATE_KEY`,
`MASKINPORTEN_CLIENT_ID`, `KID` and `SCOPE`:

```python
from pymaskinporten import request_maskinporten_token

access_token, expires_in = request_maskinporten_token("test")  # or "prod"
```

`api_env` must be `"test"` or `"prod"`; anything else raises `ValueError`. A failed token request
raises `MaskinportenTokenError`, with Maskinporten's HTTP `status`, OAuth error `code` (e.g.
`invalid_scope`) and `description` as attributes when there was a response:

```python
from pymaskinporten import MaskinportenTokenError

try:
    access_token, expires_in = request_maskinporten_token("test")
except MaskinportenTokenError as e:
    print(e.status, e.code, e.description)
```

### Secret stores

> [!NOTE]
> The secret-store loaders are tested only against mocked SDKs, not against real Azure Key
> Vault, Databricks, AWS or Google Cloud services. Please report problems in the
> [issue tracker](https://github.com/NorwegianVeterinaryInstitute/pymaskinporten/issues).

`load_config()` can also read the credentials from a secret store. Pass the result with `config=`:

```python
from pymaskinporten import load_config, request_maskinporten_token

config = load_config("azure", key_vault_uri="https://my-vault.vault.azure.net/")
access_token, expires_in = request_maskinporten_token("prod", config=config)
```

| Store                 | Call                                                         | Install                              |
|-----------------------|--------------------------------------------------------------|--------------------------------------|
| Azure Key Vault       | `load_config("azure", key_vault_uri="https://...")`          | `pip install "pymaskinporten[azure]"` |
| Databricks secrets    | `load_config("databricks", secret_scope="maskinporten")`     | `pip install "pymaskinporten[databricks]"` |
| AWS Secrets Manager   | `load_config("aws", region="eu-north-1")`                    | `pip install "pymaskinporten[aws]"`  |
| Google Secret Manager | `load_config("gcp", project_id="my-project")`                | `pip install "pymaskinporten[gcp]"`  |

Each store signs in with its SDK's default credentials: `DefaultAzureCredential` for Azure,
the Databricks runtime for Databricks, boto3's credential chain for AWS (`region` falls back to
boto3's configured region), and Application Default Credentials for Google (the latest secret
version is read).

Secret stores use dashes in the names, because Key Vault doesn't allow underscores:

| Credential        | Environment variable     | Secret name              |
|-------------------|--------------------------|--------------------------|
| Private key (PEM) | `PRIVATE_KEY`            | `PRIVATE-KEY`            |
| Client ID         | `MASKINPORTEN_CLIENT_ID` | `MASKINPORTEN-CLIENT-ID` |
| Key ID            | `KID`                    | `KID`                    |
| Scope             | `SCOPE`                  | `SCOPE`                  |

### Name prefixes

`prefix` is put in front of every environment variable or secret name, so test and prod
credentials can live side by side:

```python
# Reads PROD_PRIVATE_KEY, PROD_KID, ...
load_config(prefix="PROD_")

# Reads maskinporten/prod/PRIVATE-KEY, ...
load_config("aws", prefix="maskinporten/prod/")
```

The prefix is used as-is, so use only characters the source allows (besides letters and digits):

- Environment variables: `_`
- Azure Key Vault: `-`
- Google Secret Manager: `-` `_`
- Databricks: `-` `_` `.`
- AWS Secrets Manager: `-` `_` `/` `+` `=` `.` `@`

### Passing credentials directly

To use credentials from anywhere else, build them yourself and pass them with `config=`:

```python
from pymaskinporten import MaskinportenSecrets, request_maskinporten_token

secrets = MaskinportenSecrets(
    PRIVATE_KEY=open("private_key.pem").read(),
    MASKINPORTEN_CLIENT_ID="your-client-id",
    KID="your-kid",
    SCOPE="your-scope",
)
access_token, expires_in = request_maskinporten_token("prod", config=secrets)
```

### Using a `.env` file

By default, `load_config()` reads environment variables only. To keep the credentials in a `.env`
file, install `python-dotenv` (`pip install python-dotenv`) and load it before requesting a
token:

```python
from dotenv import load_dotenv

load_dotenv()
```

The demo app below does this for you.

### Reusing tokens

`request_maskinporten_token` fetches a new token on every call. To reuse a token until shortly
before it expires, keep a `MaskinportenClient` around and call `get_token()` whenever you need one:

```python
from pymaskinporten import MaskinportenClient

client = MaskinportenClient(api_env="prod")  # reads the config once with load_config()

headers = {"Authorization": f"Bearer {client.get_token()}"}
```

It fetches a new token `refresh_margin` seconds (default 10) before the old one expires. It is
thread-safe: concurrent callers share one request. In async code, call it with
`await asyncio.to_thread(client.get_token)`. It takes the same `token_url` and `audience`
overrides as `request_maskinporten_token`.

### Other endpoints

`api_env` picks the token endpoint and the JWT audience. To call another server, such as a local
mock, override them:

```python
access_token, expires_in = request_maskinporten_token(
    token_url="http://localhost:6969/token",
    audience="http://localhost:6969/",
)
```

Always override `audience` together with `token_url`. With `token_url` alone, the JWT is still
made out to the real Maskinporten, and the other server could use it there for up to a minute.

### Checking a setup

`pymaskinporten doctor` requests a token with your credentials and, if Maskinporten refuses,
explains the likely cause (wrong scope, unregistered key, rejected JWT, network) with a checklist.
It never prints the private key or the token.

```bash
pymaskinporten doctor                    # test environment, credentials from environment variables
pymaskinporten doctor --env prod --deployment azure --key-vault-uri https://my-vault.vault.azure.net/
pymaskinporten doctor --help             # all options, the same as load_config()
```

### Testing against Maskinporten

To check a change against the real test environment, create a Maskinporten integration for a
synthetic organisation (syntetisk organisasjon) in
[Digdir's test self-service](https://sjolvbetjening.test.samarbeid.digdir.no/), then from the repo
root:

```bash
uv run --extra web python scripts/make_test_key.py   # writes a new .env with a key pair
# paste PUBLIC_KEY from .env into the integration, fill in KID, MASKINPORTEN_CLIENT_ID, SCOPE
uv run --extra web python scripts/real_token_test.py
```

## Docker container 

To test the library in a web browser, use the `pymaskinporten-web` demo app (a `flask` app).
Run it locally with `pip install "pymaskinporten[web]"` and `pymaskinporten-web`, or use the
Docker image:

```bash
cat > .env <<EOF
MASKINPORTEN_CLIENT_ID=your-client-id
KID=your-kid
SCOPE=your-scope
EOF

docker run -p 127.0.0.1:5000:5000 --env-file .env \
  -e PRIVATE_KEY="$(cat private_key.pem)" \
  ghcr.io/norwegianveterinaryinstitute/pymaskinporten:main
```

`--env-file` keeps quotes as part of the value and doesn't support multi-line values, so leave
the values unquoted and pass the PEM key with `-e`. In PowerShell, use
`-e PRIVATE_KEY="$(Get-Content -Raw private_key.pem)"`.

Then open <http://127.0.0.1:5000>.

> [!WARNING]
> The demo app is for local testing only. Its `/token` endpoint returns real access tokens
> (including prod) without any authentication. `pymaskinporten-web` listens on `127.0.0.1` by
> default, but inside the Docker image it listens on all interfaces, so always publish the port
> with the `127.0.0.1:` prefix as shown above.
