# OIDC / SIOP v2 Integration Guide

ForTrust oferă un bridge **SIOP v2 peste OpenID4VP** pentru Relying Parties terți.
Aplicația ta vorbește OIDC clasic (`/authorize`, `/userinfo`) — portofelul EUDI al
utilizatorului face prezentarea VP-ului cu credențialele lui. Tu nu atingi mdoc,
COSE, trust chain sau SD-JWT VC; primești pur și simplu un JSON cu claim-urile
verificate.

## TL;DR (2 minute)

```bash
# 1. Inițiază o sesiune OIDC
curl "https://issuer.example.org/oidc/authorize/?\
client_id=my-app&\
redirect_uri=https://my-app/callback&\
nonce=$(openssl rand -hex 16)&\
scope=openid+profile+eudi_pid&\
state=$(openssl rand -hex 16)&\
format=json"

# → { "session_id": "...", "wallet_uri": "mdoc-openid4vp://...",
#     "userinfo_uri": ".../oidc/userinfo/<session_id>/",
#     "expires_at": "..." }

# 2. Arată QR-ul din wallet_uri utilizatorului. Când scanează,
#    backend-ul ForTrust procesează VP-ul asincron.

# 3. Poll pe userinfo sau așteaptă callback la redirect_uri
curl "https://issuer.example.org/oidc/userinfo/<session_id>/"
# → 202 pending, apoi 200 cu claims când e completed
```

## Arhitectura flow-ului

```
┌──────────┐  1 /authorize    ┌──────────────┐  2 request_uri  ┌──────────┐
│ RP (tu)  │─────────────────▶│   ForTrust   │◀────────────────│  wallet  │
│          │                  │  OIDC bridge │                 │  (phone) │
│          │  5 callback +    │              │  3 vp_token     │          │
│          │    userinfo      │              │◀────────────────│          │
│          │◀─────────────────│              │  4 verify +     │          │
└──────────┘                  └──────────────┘     store claims└──────────┘
```

1. RP → `GET /oidc/authorize/` cu client_id, redirect_uri, nonce, scope, state
2. Wallet fetch-uiește JWT request_object de la `/oidc/request/<state>/`
3. Wallet prezintă VP-ul via `POST /oidc/response/` (signed ECDSA)
4. Bridge-ul verifică semnătura + trust chain + policy + nonce match
5. RP este notificat la `redirect_uri?code=<session_id>&state=<rp_state>` și
   preia claim-urile la `GET /oidc/userinfo/<session_id>/`

## Endpoints

| Endpoint | Metodă | Cine apelează | Scop |
|---|---|---|---|
| `/.well-known/openid-configuration` | GET | oricine | Discovery metadata (RFC 8414) |
| `/oidc/authorize/` | GET | RP | Inițiază o sesiune, returnează QR + IDs |
| `/oidc/request/<state>/` | GET | wallet | Signed JWT request_object pentru VP |
| `/oidc/response/` | POST | wallet | direct_post cu `vp_token` + `presentation_submission` |
| `/oidc/userinfo/<session_id>/` | GET | RP | Claim-uri verificate (JSON) |
| `/oidc/jwks.json` | GET | oricine | Cheile publice ES256 |
| `/oidc/demo/` | GET | dev/debug | Playground vizual end-to-end |
| `/admin/oidc/oidcsession/` | GET | staff | Inspectare sesiuni |

## Scope reference

Scope-ul OIDC trimis la `/oidc/authorize/` controlează **două** lucruri:
(a) ce atribute cere wallet-ul din credențial (minimization), și
(b) ce atribute returnează `/userinfo/` la RP.

| Scope | Flow wallet | Atribute returnate |
|---|---|---|
| `openid` (min) | identity (PID complet) | toate |
| `openid profile` | identity | family_name, given_name, birth_date, birth_place, nationality, sex, portrait |
| `openid profile address` | identity | + resident_address |
| `openid eudi_pid` | identity | toate PID-urile standard EUDI |
| `openid age_over_18` | age-only | **doar** `age_over_18` (boolean) |

Echivalența cu verifier-ul mdoc:

- `scope=openid+profile+eudi_pid` ≡ `/verifier/v1/identity/`
- `scope=openid+age_over_18` ≡ `/verifier/v1/age/`

## Parametrii `/oidc/authorize/`

| Param | Obligatoriu | Notă |
|---|---|---|
| `client_id` | da | identificator al RP-ului (orice string, e vizibil în admin) |
| `redirect_uri` | da | URL unde utilizatorul e trimis după verificare |
| `nonce` | da | random unic per sesiune, prevenire replay |
| `scope` | no (default `openid`) | vezi tabelul de mai sus |
| `state` | no | CSRF token al RP-ului, e echoat în callback |
| `response_type` | no (default `vp_token`) | `vp_token` sau `id_token vp_token` |
| `format` | no | `json` → răspuns JSON în loc de HTML cu QR |

## Exemple

### Python (RP backend)

```python
import secrets, requests, urllib.parse

ISSUER = "https://issuer.example.org"

def start_login(redirect_uri):
    nonce = secrets.token_urlsafe(24)
    state = secrets.token_urlsafe(24)
    r = requests.get(f"{ISSUER}/oidc/authorize/", params={
        "client_id": "my-app",
        "redirect_uri": redirect_uri,
        "nonce": nonce,
        "scope": "openid profile eudi_pid",
        "state": state,
        "response_type": "vp_token",
        "format": "json",
    })
    r.raise_for_status()
    data = r.json()
    # Persistă state+nonce+session_id în session-ul web al utilizatorului.
    return data  # { session_id, wallet_uri, userinfo_uri, ... }

def handle_callback(code, state, expected_state):
    assert state == expected_state, "CSRF state mismatch"
    r = requests.get(f"{ISSUER}/oidc/userinfo/{code}/")
    if r.status_code == 202:
        return {"pending": True}
    r.raise_for_status()
    info = r.json()
    claims = info["claims"]
    # ex: claims["given_name"], claims["family_name"], claims["birth_date"]
    return info
```

### Node.js (RP backend)

```js
import crypto from "crypto";

const ISSUER = "https://issuer.example.org";

export async function startLogin(redirectUri) {
  const nonce = crypto.randomBytes(24).toString("base64url");
  const state = crypto.randomBytes(24).toString("base64url");
  const u = new URL(`${ISSUER}/oidc/authorize/`);
  u.searchParams.set("client_id", "my-app");
  u.searchParams.set("redirect_uri", redirectUri);
  u.searchParams.set("nonce", nonce);
  u.searchParams.set("scope", "openid profile eudi_pid");
  u.searchParams.set("state", state);
  u.searchParams.set("response_type", "vp_token");
  u.searchParams.set("format", "json");

  const r = await fetch(u);
  if (!r.ok) throw new Error(`authorize ${r.status}`);
  return await r.json();
}

export async function fetchUserinfo(code) {
  const r = await fetch(`${ISSUER}/oidc/userinfo/${code}/`);
  if (r.status === 202) return { pending: true };
  if (!r.ok) throw new Error(`userinfo ${r.status}`);
  return await r.json();
}
```

### Browser JS (redirect flow, fără backend)

```html
<a id="login" href="#">Sign in with ForTrust</a>
<script>
  const ISSUER = "https://issuer.example.org";
  const rand = (n=16) => [...crypto.getRandomValues(new Uint8Array(n))]
    .map(b => b.toString(16).padStart(2, "0")).join("");

  document.getElementById("login").addEventListener("click", () => {
    const state = rand();
    const nonce = rand();
    sessionStorage.setItem("oidc_state", state);
    sessionStorage.setItem("oidc_nonce", nonce);
    const u = new URL(`${ISSUER}/oidc/authorize/`);
    u.searchParams.set("client_id", "my-spa");
    u.searchParams.set("redirect_uri", location.origin + "/callback.html");
    u.searchParams.set("scope", "openid profile");
    u.searchParams.set("nonce", nonce);
    u.searchParams.set("state", state);
    u.searchParams.set("response_type", "vp_token");
    location.href = u;
  });
</script>
```

În `callback.html`:

```js
const params = new URLSearchParams(location.search);
const code = params.get("code");
const state = params.get("state");
if (state !== sessionStorage.getItem("oidc_state")) {
  document.body.textContent = "CSRF state mismatch";
} else {
  fetch(`${ISSUER}/oidc/userinfo/${code}/`)
    .then(r => r.json())
    .then(info => console.log("claims:", info.claims));
}
```

## Răspunsul `/oidc/userinfo/<session_id>/`

```json
{
  "sub": "sha256:7a2e5c8f...",
  "iss": "https://issuer.example.org",
  "aud": "my-app",
  "nonce": "...",
  "scope": "openid profile eudi_pid",
  "verified_at": "2026-04-21T08:45:33+00:00",
  "claims": {
    "family_name": "MINEU",
    "given_name": "MIHAELA ELENA",
    "birth_date": "1980-07-19",
    "age_over_18": true,
    "nationality": ["RO"],
    "issuing_country": "RO",
    "document_number": "103113",
    "portrait": "b64:/9j/4AAQSkZJRg..."
  }
}
```

Note:

- `sub` este un hash pairwise (SHA-256 din `family_name|given_name|birth_date|client_id`).
  Se păstrează stabil pentru același utilizator + aceeași aplicație, diferă între RP-uri.
- `portrait` vine base64-encoded cu prefix `b64:`. Pentru afișare, strip-uiește `b64:` și
  folosește ca `data:image/jpeg;base64,...`.
- `age_over_18` e boolean. Pentru flow-ul age-only (scope `age_over_18`) e singurul claim prezent.

## Coduri de răspuns

| Endpoint | Status | Când |
|---|---|---|
| `/authorize/` | 400 `invalid_request` | lipsesc params obligatorii |
| `/authorize/` | 400 `invalid_scope` | scope-ul nu conține `openid` |
| `/userinfo/` | 202 `authorization_pending` | sesiunea e încă PENDING |
| `/userinfo/` | 200 | sesiunea e COMPLETED |
| `/userinfo/` | 400 `verification_failed` | VP verification a picat |
| `/userinfo/` | 410 `session_expired` | a depășit `OIDC_SESSION_TTL_SECONDS` |
| `/userinfo/` | 404 `session_not_found` | session_id necunoscut |
| `/response/` | 400 `invalid_request` | lipsesc vp_token+state |
| `/response/` | 400 `nonce_mismatch` | wallet-ul n-a folosit nonce-ul așteptat |
| `/response/` | 409 `session_already_terminal` | s-a mai primit un VP |

## Discovery

```bash
curl https://issuer.example.org/.well-known/openid-configuration | jq
```

Returnează un document standard OIDC core cu `issuer`, `authorization_endpoint`,
`userinfo_endpoint`, `jwks_uri`, `scopes_supported`, `claims_supported`,
`vp_formats_supported` (mso_mdoc, vc+sd-jwt).

## Rulează demo-ul local

După pornirea backend-ului (`python manage.py runserver 0.0.0.0:8000`):

1. Deschide **http://<host>:8000/oidc/demo/** — o pagină fictivă "ACME Travel" cu
   buton "Sign in with ForTrust".
2. Alege scope-ul (identity / age / profile).
3. Apasă butonul → pagina cu QR din `/oidc/authorize/`.
4. Scanează QR cu portofelul EUDI.
5. La completare, browser-ul afișează claim-urile live, iar demo-RP-ul primește
   callback la `/oidc/demo/callback/?code=<session_id>&state=<rp_state>`.

Pentru inspectare: `/admin/oidc/oidcsession/`.

## Securitate & operare

- **Verificare semnătură:** bridge-ul refolosește `verifier.views._verify_standard_v1`,
  care validează COSE_Sign1 cu cheia din MSO, verifică trust chain-ul x5c împotriva
  trust registry-ului, rulează revocation check pe status list, și aplică
  limit_disclosure (ARF §6).
- **Nonce:** nonce-ul OIDC trimis de RP este folosit ca session transcript nonce
  pentru mdoc. Dacă wallet-ul reutilizează un nonce vechi → FAIL.
- **Policy check:** claim-urile prezentate sunt comparate cu `RelyingParty.allowed_attributes`
  al client_id-ului. RP-ul trebuie înregistrat în `/admin/rp_registry/relyingparty/` ca să
  scape de policy warnings.
- **TTL:** `OIDC_SESSION_TTL_SECONDS` (default 600). Setează în `.env` pentru a-l schimba.
- **HTTPS:** în producție (`DEPLOY_PROFILE=prod`) toate endpoint-urile sunt HTTPS-only
  (`SECURE_SSL_REDIRECT`, HSTS preload, CSRF secure cookies).

## Network access via PID (ARF 2.8.0 §3.11)

`scope=openid network_access` este alternativa **ARF-compliant** la `wifi`:
nu cere un credențial dedicat WiFi, ci doar **atribute PID** pe care RP-ul
(hotspot-ul, operatorul rețelei) le folosește ca bază pentru a acorda
acces. Modelul se bazează pe §3.11 — *"Relying Parties request attributes;
based on those, they make access decisions"*.

Soluția rezolvă direct gap-ul §4.4.3.1 "Wallet Unit Selection": dacă
utilizatorul are două wallet-uri (unul mdoc-PID, altul cu Pas WiFi), OS-ul
nu are cum să rute-eze `mdoc-openid4vp://` pentru doctype WiFi la
wallet-ul potrivit. Cu `network_access` wallet-ul mdoc răspunde direct —
nu e nevoie de un Pas WiFi separat.

**Flow:**

1. RP: `GET /oidc/authorize/?scope=openid network_access&...`
2. Bridge cere PID (`family_name`, `given_name`, `birth_date`,
   `age_over_18`, `nationality`, `issuing_country`).
3. Wallet-ul mdoc prezintă PID → bridge verifică semnătura.
4. Bridge rulează `settings.NETWORK_ACCESS_POLICY` asupra claim-urilor.
5. Dacă politica e PASS → bridge sintetizează `{ssid, password}` și
   construiește URI-ul `WIFI:...`. Dacă e FAIL → răspunsul include
   `network_access_granted=false` și motivul.

**Configurare policy (`.env` / `settings.py`):**

```jsonc
NETWORK_ACCESS_POLICY='{
  "ssid": "acasa24",
  "password": "parola-reala",
  "require_age_over_18": true,
  "allowed_nationalities": ["RO"],
  "allowed_issuing_countries": ["RO"]
}'
```

Policy-check e server-side, ceea ce înseamnă că parola nu iese niciodată
pe telefon în clear până când bridge-ul nu a verificat PID-ul ARF-valid.

**Claim-uri returnate la `/userinfo/`** pentru `scope=openid network_access`:

```json
{
  "claims": {
    "family_name": "MINEU",
    "given_name": "MIHAELA ELENA",
    "birth_date": "1980-07-19",
    "age_over_18": true,
    "nationality": ["RO"],
    "issuing_country": "RO",
    "network_access_granted": true,
    "network_access_reason": "granted by PID policy",
    "ssid": "acasa24",
    "wifi_uri": "WIFI:T:WPA;S:acasa24;P:...;H:false;;",
    "wifi_qr_b64": "..."
  }
}
```

## WiFi auto-connect flow

`scope=openid wifi` cere o prezentare a credențialului WiFi (doctype
`ro.fortrust.wifi.access.1`). Bridge-ul verifică VP-ul, extrage SSID-ul +
perioada de valabilitate, și construiește un URI de forma:

```
WIFI:T:<auth>;S:<ssid>;P:<password>;H:false;;
```

Parola **nu** se ia din credențial (ARF-aligned: credentialul certifică
drepturi de acces, nu parolă WPA). Sursele, în ordinea priorității:

1. **`claims["password"]`** — dacă credențialul conține un atribut custom `password` (rar).
2. **`WIFI_ACCESS_PASSWORDS={"ssid": "..."}`** — map JSON în `.env`/settings; recomandat pentru demo multi-SSID.
3. **`WIFI_ACCESS_DEFAULT_PASSWORD=...`** — fallback single-SSID.
4. **Fără parolă** → `T:nopass` (wallet-ul se autentifică EAP out-of-band sau rețeaua e deschisă).

Răspunsul `/userinfo/` pentru flow-ul WiFi include câmpul derivat
`wifi_password_source` pe una dintre valorile: `wallet_credential`,
`server_map`, `server_default`, `none_eap_or_open`. Aplicația RP poate
afișa un badge operatorului ("parolă configurată de server").

În producție, omite ambele variabile — deployment-urile reale folosesc
EAP-TLS/Passpoint, iar `WIFI:` QR-ul cu `T:nopass` e suficient pentru a
pre-umple SSID-ul pe telefon, autentificarea făcându-se prin
credențialul prezentat (WPA2-Enterprise EAP).

## BBS+ unlinkable presentations (ARF §7.4.3.5.2)

Pentru maximum-privacy flows (ex. guest WiFi hotspot care nu trebuie să
poată corela vizitatori cross-sesiune), wallet-ul poate prezenta VP-uri
în format `ldp_vp` cu `BbsBlsSignatureProof2020`. Backend-ul expune:

- `POST /bbs/issue/`      — emitere VC semnat BBS+ (rulează în Node sidecar)
- `POST /bbs/derive/`     — derivarea unui proof ZKP unlinkable din VC
- `POST /bbs/verify/`     — verificare proof la RP
- `GET  /bbs/supported-ciphersuites/` — ciphersuite enumeration

`/.well-known/openid-configuration` advertizează `ldp_vp` în
`vp_formats_supported` ca al treilea format pe lângă `mso_mdoc` și
`vc+sd-jwt`. Wallet-ul alege ce format cere verificatorul per
request_object prin `client_metadata.vp_formats`.

Environment: `BBS_SERVICE_URL=http://127.0.0.1:3001` (default) — fără
sidecar-ul Node, endpoint-urile /bbs/* returnează 503.

## Vezi și

- **Verifier mdoc QR (fără OIDC):** `/verifier/v1/identity/`, `/verifier/v1/age/`
- **Trust registry:** `/admin/trust_registry/trustedentity/`
- **RP policy:** `/admin/rp_registry/relyingparty/`
- **Deploy readiness:** `/admin/deploy-status/` (vezi ce mai lipsește pentru prod)
- **Spec:** OpenID Connect Core 1.0, OID4VP Draft 22, SIOP v2 (OpenID4VC)
