Metadata-Version: 2.4
Name: django-idaustria
Version: 0.2.3
Summary: ID Austria identification for Django via OpenID Connect
Author-email: Christian González <christian.gonzalez@nerdocs.at>
License: The MIT License (MIT)
        =====================
        
        Copyright © 2026 Christian González
        
        Permission is hereby granted, free of charge, to any person
        obtaining a copy of this software and associated documentation
        files (the “Software”), to deal in the Software without
        restriction, including without limitation the rights to use,
        copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the
        Software is furnished to do so, subject to the following
        conditions:
        
        The above copyright notice and this permission notice shall be
        included in all copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
        EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
        OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
        NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
        HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
        WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
        FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
        OTHER DEALINGS IN THE SOFTWARE.
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Web Environment
Classifier: Framework :: Django
Classifier: Framework :: Django :: 5.2
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Topic :: Security
Requires-Python: >=3.12
Description-Content-Type: text/markdown
License-File: LICENSE.md
Requires-Dist: django>=6.0.4
Requires-Dist: requests>=2.33.0
Requires-Dist: PyJWT[crypto]>=2.9
Requires-Dist: django-environ>=0.13.0
Dynamic: license-file

# django-idaustria

A reusable Django app for [ID Austria](https://www.id-austria.gv.at/) identification via OpenID Connect.

It deliberately separates **identification** (proving a user corresponds to a real Austrian citizen) from **authentication** (logging in). A typical use case is a signup flow where the site needs to be sure a new account belongs to a specific person; after identification, the user continues to log in with their regular credentials.

## Features

- OIDC Authorization Code Flow against the ID Austria reference and production IdP
- JWT ID token validation via the IdP's JWKS
- Optional PKCE support
- Typed `IDAustriaIdentity` view over the claims (bPK, names, birthdate, address, eIDAS level, …) with automatic decoding of the Base64-JSON `mainAddress`
- `identification_completed` signal fired on every outcome (success *and* error)
- Session-based "pending identification" decoupled from user auth — bind to a user later with `bind_pending_identification()`
- Structured `AuthorizationError` for IdP-reported failures (e.g. `access_denied`)
- **Vollmacht / Vertretung (USP)** — typed `MandateInfo` for representation logins (both juristic and natural mandators); optional `MANDATE_REQUIRED` strict mode

## Installation

```bash
pip install django-idaustria
```

```python
# settings.py
INSTALLED_APPS = [
    # ...
    "idaustria",
]

# Mandatory — from your IDA-SP registration
IDAUSTRIA_CLIENT_ID = "https://your.app.identifier"        # a URL, not a random string
IDAUSTRIA_CLIENT_SECRET = "<client-secret>"
IDAUSTRIA_REDIRECT_URI = "https://your.site/idaustria/callback/"  # must match IDA-SPR verbatim

# Optional
IDAUSTRIA_ENV = "ref"                # or "prod" (default: "ref")
IDAUSTRIA_SCOPES = ("openid", "profile")
IDAUSTRIA_USE_PKCE = False
IDAUSTRIA_TIMEOUT = 10.0
```

Wire the URLs:

```python
# urls.py
from django.urls import include, path

urlpatterns = [
    # ...
    path("idaustria/", include(("idaustria.urls", "idaustria"), namespace="idaustria")),
]
```

Migrate the database:
```bash
python manage.py migrate
```

The *django-idaustria* app only provides one Django model: `Identification`. It keeps a record of successful ID Austria identifications as a 1:n
relation to your application's user model (it uses `settings.AUTH_USER_MODEL`). 

## Usage

### 1. Trigger the flow

Render a link or button pointing to `reverse("idaustria:start")`. The user is redirected to the ID Austria IdP and, after completion, back to `idaustria:callback`. The callback endpoint:

1. exchanges the authorization code for tokens (`client_secret_post`)
2. validates the `id_token` against the IdP's JWKS
3. stores the result in the session under a random `pending_id`
4. fires the `identification_completed` signal
5. returns JSON: `{"ok": true, "claims": {...}, "pending_id": "..."}`

On failure the callback returns a structured error, e.g. `{"ok": false, "error": "access_denied", "error_description": "..."}`.

### 2. Consume the identification

```python
from django.dispatch import receiver
from idaustria import IDAustriaIdentity, bind_pending_identification
from idaustria.signals import identification_completed


@receiver(identification_completed)
def on_idaustria_completed(sender, request, success, identity, tokens, error, **kwargs):
    if not success:
        # error is an IDAustriaError subclass (AuthorizationError, StateMismatchError, …)
        return

    # identity is an IDAustriaIdentity — typed view over the claims
    bpk = identity.bpk                    # stable person identifier
    name = identity.full_name             # "Alice Example"
    birthdate = identity.birthdate        # datetime.date | None
    address = identity.main_address       # MainAddress | None (auto-decoded)
    is_adult = identity.age_over(18)      # bool | None
    raw = identity.claims                 # raw dict, if you need a claim not surfaced

    # You probably want to persist the identification. Do it later, when
    # you know which user to bind it to (e.g. after signup completes):
```

```python
# later, in your signup finalization view:
def finalize_signup(request):
    user = ...  # the user you just created or logged in
    identification = bind_pending_identification(request, user)
    # identification is now an Identification row, or None if nothing was pending
```

### 3. Use `bpk`, not `sub`

At ID Austria the OIDC `sub` claim is **transient** — it changes on every login. The only stable identifier for a person is the bPK (bereichsspezifisches Personenkennzeichen), exposed as `identity.bpk` (underlying claim `urn:pvpgvat:oidc.bpk`). Use `bpk` for recognizing returning users.

### 4. Vollmacht / Vertretung (USP)

When the application is registered with mandate profiles in the IDA-SPR, users can sign in *on behalf of* another entity — typically a company via USP, but also a natural person. The id_token then carries the **representative's** personal claims (bpk, name, …) PLUS the mandator's data as separate claims.

```python
@receiver(identification_completed)
def on_idaustria_completed(sender, success, identity, **kwargs):
    if not success:
        return
    if identity.is_mandated:
        m = identity.mandate
        # m.kind == "legal"  -> company (USP); identifier = KUR/Stammzahl
        # m.kind == "natural" -> private person; identifier = bPK
        print(f"{identity.full_name} acting on behalf of {m.name} ({m.mandate_type})")
    else:
        print(f"personal identification: {identity.full_name}")
```

After `bind_pending_identification()`, the same fields are denormalised on the model: `identification.is_mandated`, `identification.mandator_kind`, `identification.mandator_identifier`, `identification.mandator_name`, `identification.mandate_type`. The full raw mandate claims remain available via `identification.claims`.

See [`docs/Mandate.md`](docs/Mandate.md) for the full claim catalogue and the `IDAUSTRIA_REQUEST_MANDATE_CLAIMS` / `IDAUSTRIA_MANDATE_REQUIRED` settings.

## Notes

- ID Austria supports only the Authorization Code Flow and requires a `client_secret`.
- `client_id` must be a URL inside your application (this is an IDA-SPR convention, not an OAuth requirement).
- Attributes (name, address, age-over, …) are **not** selected via OIDC scopes — they are configured per service provider in IDA-SPR. Request only `openid profile` for compatibility.
- The callback endpoint does not require authentication — it deliberately runs anonymously so sign-up flows work.
- Up to 5 pending identifications are retained per session; older entries are pruned.

## Development

See [`CLAUDE.md`](./CLAUDE.md) for architecture notes and the `demo/` project for a runnable example.

```bash
cd demo
uv run python manage.py migrate
uv run python manage.py runserver
```

Tests:

```bash
uv run python -m pytest
```

## License

MIT — see [`LICENSE.md`](./LICENSE.md).
