solid-pod-rs-idp 0.4.0-alpha.4

Solid-OIDC identity provider (authorization-code + DPoP-bound tokens, JWKS, credentials, dynamic client registration) — Rust port of JavaScriptSolidServer/src/idp
Documentation
# solid-pod-rs-idp

**Status: 0.4.0-alpha.2 — Sprint 10–12 Solid-OIDC provider.**

Rust port of the JSS identity provider (`JavaScriptSolidServer/src/idp/*`).
This crate owns the **protocol** surface; transport framing is the
consumer's decision (enable `axum-binder` for a ready-made Router,
or plug `Provider` into any router you like).

## What landed in Sprint 10

Parity rows flipped from `missing` → `present` (tracked in
`../../docs/PARITY-CHECKLIST.md`):

| Row | Endpoint / feature                   | JSS ref                        |
|----:|--------------------------------------|--------------------------------|
|  74 | `/idp/auth` — authorization-code flow | `src/idp/provider.js:307-317`  |
|  75 | `/idp/reg` — Dynamic Client Registration | `src/idp/provider.js:147-156`  |
|  76 | `/.well-known/openid-configuration`  | `src/idp/index.js:203-237`     |
|  77 | `/.well-known/jwks.json`             | `src/idp/index.js:240-244`     |
|  78 | Client Identifier Documents (SSRF-guarded) | `src/idp/provider.js:22-85`    |
|  79 | `/idp/credentials` (email+password + rate-limit) | `src/idp/credentials.js`       |
| 130 | JWKS publication (IdP side)          | `src/idp/keys.js`              |

## WebAuthn + Schnorr SSO (rows 80, 81)

Sprint 11 lands real backends for both rows:

| Row | Backend | Feature flag | Notes |
|----:|---------|--------------|-------|
|  80 | [`WebauthnPasskey`] on top of `webauthn-rs` 0.5 | `passkey` | Reasonable defaults: user-verification required, `EdDSA`+`ES256`, single-step registration, in-memory challenge/credential store. Swap for a persistent store via a custom [`PasskeyBackend`] impl. |
|  81 | [`Nip07SchnorrSso`] on top of core `nip98-schnorr` | `schnorr-sso` | 32-byte CSPRNG challenges, 5-minute default TTL, one-shot consume-on-verify. Canonical digest is `SHA-256(token ‖ user_id ‖ pubkey)`. |

The trait types ([`PasskeyBackend`], [`SchnorrSso`]) stay stable so
integrators who want to bring their own backend — e.g. attestation-
pinned WebAuthn, or Redis-backed Schnorr state — can swap the default
impl without touching `Provider`.

The zero-op `PasskeyTodo` and `SchnorrTodo` types remain as
`#[doc(hidden)]` fallbacks: useful for wiring a provider up in tests
before deciding which backend to enable.

## What is `wontfix-in-crate`

| Row | Why                                  |
|----:|--------------------------------------|
|  82 | HTML interaction pages (login / consent / register). JSS bundles Handlebars templates in `src/idp/views.js`. We do not ship a view layer because the right choice depends on the consumer's existing stack (Askama, Leptos, Tera, Yew, or plain `format!`). A minimal Askama adapter on top of this crate is < 300 LOC and should live in a host-app crate where the operator controls the HTML. |

## Authorization code flow

```mermaid
sequenceDiagram
    participant App as Client App
    participant IDP as IdP Provider
    participant US as UserStore
    participant SS as SessionStore
    participant JWKS as JWKS (ES256)

    Note over App: Discovery
    App->>IDP: GET /.well-known/openid-configuration
    IDP-->>App: issuer, endpoints, DPoP algs, PKCE

    Note over App: Dynamic Client Registration
    App->>IDP: POST /idp/reg {redirect_uris}
    IDP-->>App: {client_id, client_secret}

    Note over App: Authorization
    App->>IDP: GET /idp/auth?code_challenge=S256(verifier)
    IDP->>US: find_by_email + verify_password
    Note over IDP: Password ≥ 8 chars (CWE-521)
    US-->>IDP: User {webid, id}
    IDP->>SS: create_session + issue auth_code
    IDP-->>App: 302 → redirect_uri?code=…

    Note over App: Token Exchange
    App->>IDP: POST /idp/token {code, code_verifier, DPoP proof}
    IDP->>SS: consume auth_code (single-use)
    IDP->>JWKS: sign access token (ES256)
    Note over IDP: cnf.jkt = DPoP thumbprint
    Note over IDP: ath = SHA-256(access_token)
    IDP-->>App: {access_token, token_type: "DPoP"}

    Note over App: Resource Access
    App->>App: Attach DPoP proof per request
```

```mermaid
flowchart LR
    subgraph cred ["Credentials endpoint (/idp/credentials)"]
        direction TB
        RL["Rate limiter<br/>10/min per IP"] --> VAL["Validate email +<br/>password (≥ 8 chars)"]
        VAL --> AUTH["UserStore lookup<br/>+ argon2id verify"]
        AUTH --> TOK["Issue access token<br/>ES256-signed JWT"]
        TOK --> BIND{"DPoP proof<br/>supplied?"}
        BIND -->|yes| DPOP["token_type: DPoP<br/>cnf.jkt bound"]
        BIND -->|no| BEARER["token_type: Bearer"]
    end

    style RL fill:#e74c3c,stroke:#c0392b,color:#fff
    style VAL fill:#f39c12,stroke:#d68910,color:#fff
    style AUTH fill:#9b59b6,stroke:#7d3c98,color:#fff
    style TOK fill:#2ecc71,stroke:#1a9850,color:#fff
```

## Minimum-viable flow

```rust,no_run
use std::sync::Arc;
use solid_pod_rs_idp::{
    Provider, ProviderConfig, Jwks, SessionStore,
    registration::ClientStore,
    user_store::{InMemoryUserStore, UserStore},
};

// 1. Seed stores.
let user_store: Arc<dyn UserStore> = Arc::new(InMemoryUserStore::new());
let client_store = ClientStore::new();
let session_store = SessionStore::new();
let jwks = Jwks::generate_es256().unwrap();

// 2. Build the provider.
let provider = Provider::new(
    ProviderConfig::new("https://pod.example/"),
    client_store,
    session_store,
    user_store,
    jwks,
);

// 3. Serve discovery + JWKS directly from the provider:
let _discovery = provider.discovery_document();
let _jwks_doc = provider.jwks().public_document();
```

## Axum binder

Enable `axum-binder` to get a Router with discovery, JWKS,
registration, and credentials pre-wired:

```toml
[dependencies]
solid-pod-rs-idp = { version = "0.4", features = ["axum-binder"] }
```

`/idp/auth` and `/idp/token` are NOT on the binder — their request
shape (session cookies, form-encoded bodies, 302 redirects) is too
app-specific for a generic binder. Wire them against your own
framework session middleware.

## Design deviations from JSS

Honest list of shape differences (not behaviour differences — those
should be zero):

1. **Signing algorithm.** JSS publishes both RS256 and ES256; we
   publish ES256 only in Sprint 10 (Solid-OIDC mandates ES256 for
   DPoP, every Solid RP we checked accepts ES256 id-tokens, and it
   skips pulling `rsa` into our dep graph). Additional algs can be
   inserted via `Jwks::insert_signing_key`.
2. **Password hash.** JSS uses `bcrypt` (`src/idp/accounts.js`);
   we use `argon2id` (stronger, OWASP-preferred). Re-hashing on
   next successful login is the consumer's migration story.
3. **Session storage.** JSS persists sessions to disk via
   `oidc-provider`'s filesystem adapter. We ship an in-memory store
   with a pluggable trait; disk persistence is the consumer's
   choice (serialise the `SigningKey::private_pem` and session
   records to their own backend).
4. **Code format.** JSS generates opaque client ids as
   `client_<base36-timestamp>_<random>`. We mirror the format.
5. **View layer.** JSS bundles Handlebars templates; we don't (see
   row 82 above).

## Sprint 12 changes

- **Password-length validation (CWE-521).** `MIN_PASSWORD_LENGTH = 8`
  constant and `validate_password_length()` helper mirror JSS commit
  `1feead2`. `LoginError::PasswordTooShort` variant returns HTTP 400
  via the Axum binder. `InMemoryUserStore::insert_user` enforces the
  same minimum at registration time.
- Re-exported: `validate_password_length`, `MIN_PASSWORD_LENGTH` from
  crate root.

## Tests

91 unit tests cover:

- Discovery document shape (`webid` in scopes, `none` auth method,
  DPoP algs, PKCE S256, issuer trailing-slash normalisation).
- JWKS publication, key rotation with retention window, prune-expired,
  round-trip through PKCS8 PEM.
- Opaque dynamic client registration + Client Identifier Documents
  (fetch, cache, id-mismatch rejection, SSRF guard trips on private
  IP, missing-redirect-uris rejection).
- Session create/lookup/revoke + authorisation-code single-use + TTL
  expiry.
- `/idp/credentials` email+password: correct password, wrong
  password, unknown user, blank input, DPoP-bound vs Bearer, rate
  limit tripping at 11th attempt.
- Authorisation-code flow end-to-end: issue code → redeem at
  `/token` → verify DPoP-bound access token. Plus negative cases
  (missing DPoP, wrong htu, PKCE mismatch, unregistered redirect,
  no PKCE attempt).
- Access-token issuance with DPoP `cnf.jkt` binding; Bearer issuance
  when no DPoP thumbprint is passed; `ath_hash` known-value check.
- Trait hook callability (passkey / schnorr null backends return
  `Unimplemented`).
- Password-length validation: too-short (7 chars), exactly 8, longer,
  empty; `MIN_PASSWORD_LENGTH` constant value.
- Registration rejects short passwords at `insert_user` time.

## Licence

AGPL-3.0-only.