diff --git a/sonar/modules/shibboleth_authenticator/README.md b/sonar/modules/shibboleth_authenticator/README.md new file mode 100644 index 000000000..9ad88eba0 --- /dev/null +++ b/sonar/modules/shibboleth_authenticator/README.md @@ -0,0 +1,609 @@ + + +# SWITCH edu-ID login flow + +How a user signs in to SONAR through SWITCH edu-ID, step by step. + +Every request below comes from a real capture of the test federation +(`login.test.eduid.ch`), taken with SAML-tracer against a development server on +`https://sonar.ch:5000`. Analytics and unrelated background traffic have been +removed; what remains is the complete flow, in order. + +## Overview + +```mermaid +sequenceDiagram + participant B as Browser + participant S as SONAR (Service Provider) + participant I as edu-ID (Identity Provider) + + B->>S: GET /shibboleth/login/eduidtest + S-->>B: 302 to the IdP, carrying AuthnRequest + RelayState + B->>I: GET /idp/profile/SAML2/Redirect/SSO?SAMLRequest=… + I-->>B: 302 to the login sequence (execution=e13s1) + + Note over B,I: Four interaction steps, all on the same URL,
distinguished only by the execution parameter + B->>I: e13s1 — username + B->>I: e13s2 — password + B->>I: e13s3 — consent to release attributes + B->>I: e13s4 — session storage probe + + I-->>B: 200, HTML page auto-submitting a form + B->>S: POST /shibboleth/authorized/eduidtest (SAMLResponse + RelayState) + S-->>B: 302, user is now logged in +``` + +## The steps + +### 1. The user asks SONAR to sign in + +```http +GET https://sonar.ch:5000/shibboleth/login/eduidtest +→ 302 https://login.test.eduid.ch/idp/profile/SAML2/Redirect/SSO?SAMLRequest=…&RelayState=… +``` + +Handled by `views/client.py::login`. It builds the SAML settings through +`auth.py::init_saml_auth`, then redirects the browser to the identity provider. +`eduidtest` is the provider key: it selects an entry of +`SHIBBOLETH_IDENTITY_PROVIDERS` and names the certificate file to trust. + +### 2. The AuthnRequest reaches the identity provider + +Sent with the **HTTP-Redirect binding**, so the request travels as URL +parameters: `SAMLRequest` (deflated then base64-encoded) and `RelayState`. +There is no `Signature` parameter — SONAR does not sign its authentication +requests. + +```xml + + https://sonar.ch/shibboleth + + + urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport + + +``` + +Three values decide whether the IdP accepts the request, and all three must +match the service registration in the Resource Registry: + +| Element | Value | Where it comes from | +|---|---|---| +| `Issuer` | `https://sonar.ch/shibboleth` | `SHIBBOLETH_SERVICE_PROVIDER["entity_id"]` — an identifier, it never has to resolve | +| `AssertionConsumerServiceURL` | `https://sonar.ch:5000/shibboleth/authorized/eduidtest` | computed by `url_for(…, _external=True)`, so it follows the `Host` header | +| `Destination` | `https://login.test.eduid.ch/…` | the `sso_url` of the provider entry | + +The `AssertionConsumerServiceURL` is the one that bites in practice: change the +hostname or the port of the development server and the IdP will reject the +request until the new URL is registered. + +> **Certificates — none.** The outbound request is protected only by the TLS +> certificate of `login.test.eduid.ch`, which belongs to SWITCH. No SAML +> certificate is involved: the captured request carries `SAMLRequest` and +> `RelayState` and **no `SigAlg` or `Signature` parameter**, so SONAR does not +> sign it. The service provider key pair plays no part until the response comes +> back. +> +> This is a default rather than a decision: `init_saml_auth` declares no +> `security` section, so `python3-saml` applies `authnRequestsSigned: False`. +> Setting it to `True` covers the SONAR side, since the private key is already +> loaded. It is not sufficient on its own: the identity provider holds the +> certificate for *encryption*, and whether it accepts the same one to verify a +> signature depends on how the key is registered in the Resource Registry. +> Reusing a single certificate for both uses has to be agreed there. The gain is +> modest: the identity provider validates the +> `AssertionConsumerServiceURL` against the registered service, so a forged +> request cannot divert the assertion, and `RelayState` integrity is already +> covered by SONAR's own signed state token. Worth doing as defence in depth, on +> its own and not bundled with other work — a configuration mismatch would break +> login outright. + +### 3. RelayState carries SONAR's own state + +`RelayState` is opaque to the IdP, which returns it untouched. SONAR uses it to +remember what the user was doing. It is signed with `SECRET_KEY` through +`itsdangerous`, and decodes to: + +```json +{ + "app": "eduidtest", + "next": "https://sonar.ch:5000/login/?next=/", + "sid": "60906f8a…d718f2a" +} +``` + +`sid` is a fingerprint of the browser session. On the way back, +`views/client.py` checks that it still matches and that `app` is the provider +being answered, which is what binds a response to the session that asked for it. + +**That check is conditional.** It sits inside `if "RelayState" in request.form`, +so a response arriving without `RelayState` reaches `authorized_signup_handler` +unbound to any session. The identity provider always echoes the value back, so +this does not happen in the normal flow — but the protection is not +unconditional, and reading it as such would be wrong. + +### 4. Authentication, in four steps on one URL + +All four use the same URL and differ only by the `execution` parameter. Each is +a `GET` that renders a page, then a `POST` that submits it and redirects to the +next. + +| execution | Page | POST fields | +|---|---|---| +| `e13s1` | Username | `j_username`, `_eventId_submit` | +| `e13s2` | Password | `j_username`, `j_password`, `_eventId_proceed` | +| `e13s3` | **Attribute consent** | `_shib_idp_consentIds` (one per attribute), `_eventId_proceed=Accepter` | +| `e13s4` | Session storage probe | `shib_idp_ls_success.shib_idp_session_ss`, `_eventId_proceed` | + +Step `e13s4` is not a user interaction. The page reads +`/idp/profile/user/system/shared-local-storage` to look for an existing single +sign-on session in the browser, then submits by itself. In the capture the probe +comes back empty — `shib_idp_ls_success.shib_idp_session_ss = false` — which is +expected on a first login. The `_ss` suffix names the browser's `sessionStorage`, +and `shib_idp_ls_success.` is how the page reports back whether the read +worked. + +What this stores is the **identity provider's own SSO session**, so that signing +in to a second service in the same browser does not ask for credentials again. +It lives in the browser, disappears when the browser closes, and writes nothing +to the edu-ID account. Do not confuse it with the consent of step `e13s3`, which +is the durable record of the user–service pair. + +### 5. Consent — what the user is actually asked + +Step `e13s3` is where the user authorises the release of each attribute. The +`_shib_idp_consentIds` fields submitted match one for one what the page lists: + +```text +commonName displayName givenName surname email +swissEduPersonHomeOrganization swissEduIDLinkedAffiliation +``` + +![Consent page shown by edu-ID](eduid-consent.png) + +*Name and e-mail are redacted; the home organisation and the linked affiliations +are the values this flow exists to obtain.* + +Consent is stored by the identity provider for the user–service pair, and it is +durable: the page is shown on the first login, then only again if one of the +released values has changed since — or if the user has asked to be prompted at +every login. It survives browser restarts, unlike the SSO session of step +`e13s4`. + +That matters when testing. Widen the attribute set in the Resource Registry and +you may see no difference, because the stored consent still covers the previous, +narrower set. To force the page back, tick the box offered on the login page to +revoke previous consent; edu-ID administrators can also reset it for a given +service or user. + +### 6. The response returns by POST + +The IdP answers step `e13s4` with an HTML page containing a form that submits +itself to the assertion consumer service: + +```http +POST https://sonar.ch:5000/shibboleth/authorized/eduidtest + SAMLResponse=… (11 829 bytes once base64-decoded) + RelayState=… (returned unchanged) +→ 302 https://sonar.ch:5000/login/?next=/ +``` + +This is why the development server needs a certificate the browser trusts. The +POST is issued by a page served from `login.test.eduid.ch`; if the browser +raises a certificate warning on `sonar.ch:5000`, clicking through generally +drops the request body, and the assertion is lost. + +The response is **signed**, and the assertion inside it is **encrypted** to +SONAR's service provider certificate: + +```xml + + https://test.eduid.ch/idp/shibboleth + + + + + + +``` + +There is no readable `AttributeStatement` in the POST body: the attributes only +appear after `python3-saml` decrypts the assertion. + +> **Certificates — three at once, and this is the densest moment of the flow.** +> +> - The **TLS certificate of the development server** protects the POST. It is +> the only one the browser checks, and the only one that has to be *trusted* +> rather than merely known — see the warning above about losing the body. +> - The IdP signed the response with its private key. SONAR verifies that +> signature against **`data/idp_certificates/eduidtest.crt`**, the copy of the +> IdP signing certificate published in the SWITCH federation metadata. +> - The IdP encrypted the assertion with **SONAR's service provider +> certificate**, which it holds from the Resource Registry registration. The +> captured `EncryptedKey` names it explicitly: +> `Recipient="https://sonar.ch/shibboleth"`. +> +> Note the asymmetry: the IdP certificate is a *trust anchor* and is legitimately +> versioned in the repository, whereas the service provider key pair is a secret +> provisioned per environment. + +**The `Issuer` must equal the identity provider's configured `entity_id`** — +that is `SHIBBOLETH_IDENTITY_PROVIDERS["eduidtest"]["entity_id"]`, not the +service provider's `SHIBBOLETH_SERVICE_PROVIDER["entity_id"]` seen in step 2. +The two are easy to confuse: the first identifies edu-ID and is what the +response and assertion `Issuer` are compared against; the second identifies +SONAR and is what the `AuthnRequest` carries. `python3-saml` rejects any +mismatch. It stays `https://test.eduid.ch/idp/shibboleth` only because the +service requires the *private identity*; were the affiliation identity allowed, +the answer would come from the institution's own IdP +(`https://aai-login..eduid.ch/idp/shibboleth`) and validation would +fail. + +### 7. Attributes, once decrypted + +> **Certificate — the service provider private key, and the order matters.** +> `python3-saml` decrypts as soon as the response object is built, *before* any +> validation: `OneLogin_Saml2_Response.__init__` sees the `EncryptedAssertion` +> and calls `_decrypt_assertion`, which uses `get_sp_key()` — that is +> `SHIBBOLETH_SERVICE_PROVIDER_PRIVATE_KEY`. Validation comes next, in `is_valid`: +> a signature on the response is checked against the original document, one on +> the assertion against the decrypted one, both with the same IdP certificate. +> +> The practical consequence: a wrong or missing private key fails at +> construction, with a decryption error, never with a signature error. If you +> see "Signature validation failed", suspect the IdP certificate; if you see a +> decryption failure, suspect the service provider key pair. + +```text +urn:oid:2.16.840.1.113730.3.1.241 displayName Camille Dupraz +urn:oid:2.5.4.3 commonName Camille Dupraz +urn:oid:2.5.4.4 surname Dupraz +urn:oid:2.5.4.42 givenName Camille +urn:oid:0.9.2342.19200300.100.1.3 mail camille.dupraz@example.org +urn:oid:2.16.756.1.2.5.1.1.1 swissEduPersonUniqueID 0000159046867193@test.eduid.ch +urn:oid:1.3.6.1.4.1.5923.1.1.1.6 eduPersonPrincipalName 0000159046867193@test.eduid.ch +urn:oid:1.3.6.1.4.1.5923.1.1.1.13 eduPersonUniqueId 0000159046867193@test.eduid.ch +urn:oid:2.16.756.1.2.5.1.1.4 swissEduPersonHomeOrganization test.eduid.ch +urn:oid:2.16.756.1.2.5.1.1.1029 swissEduIDLinkedAffiliation member@library-demo.ch + student@library-demo.ch +``` + +`handlers.py::authorized_signup_handler` reads them through +`utils.py::get_account_info`, which keeps three of them — the ones named in the +`mappings` of the provider entry. + +Two are worth a closer look. + +**`swissEduPersonHomeOrganization` is `test.eduid.ch`**, not a university. That +is the signature of the private identity: the home organisation is edu-ID +itself. The same holds for the scope of the unique identifier, which is why that +identifier stays stable even if the person changes institution. + +**`swissEduIDLinkedAffiliation` lists every current affiliation**, as +`@` pairs. Here the same institution appears twice, once as +`member` and once as `student` — the specific role always comes alongside +`member`. + +**SONAR requests this attribute but does not yet consume it.** `sonar/config.py` +maps only `email`, `full_name` and `user_unique_id`, and `get_account_info` +keeps nothing else, so the affiliations are released and dropped. Whoever adds a +membership check should look for a *pair*, never ask “what is this person's +role”: one institution yields several values, and the specific role always comes +with `member`. + +### 8. Back on SONAR + +```http +GET https://sonar.ch:5000/login/?next=/ → 302 / +GET https://sonar.ch:5000/ → 200 +``` + +By this point the account exists and the session is open. +`authorized_signup_handler` has found the user by e-mail, created the account if +needed, linked the external identity, and authenticated the session. + +The link is a `UserIdentity` row, written by `oauth_link_external_id` with +`method` set to the provider key. It is what lets a returning user be recognised +even after changing their e-mail address. + +**It does not appear under `/account/settings/linkedaccounts/`.** That page +enumerates `OAUTHCLIENT_REMOTE_APPS`, where SONAR declares only ORCID; this +module borrows a few helpers from invenio-oauthclient but never registers itself +as a remote app — it is SAML, not OAuth. So the link is recorded and invisible: +users can unlink ORCID, but not edu-ID. + +That is defensible rather than accidental. Unlinking ORCID loses an enrichment, +whereas unlinking edu-ID would lock the user out, since an account created +through SAML has no local password. The absence of a control protects as much as +it limits. + +## Which certificate does what + +Four certificates take part, and they are easy to confuse because two of them +answer to the name "SONAR". Every public part is published somewhere — in +federation metadata, in the Resource Registry, or presented during a TLS +handshake. Only the private keys stay on their respective servers. + +| Certificate | Who holds the private key | Used for | At step | +|---|---|---|---| +| **Development server TLS** — `.certs/dev.crt` + `.certs/dev.key` | SONAR | Serving `https://sonar.ch:5000`. Validated by the browser, and the one that has to be *trusted* rather than merely present — the return POST is lost otherwise. | 1, 6, 8 | +| **`login.test.eduid.ch` TLS** | SWITCH | Serving the identity provider. Also validated by the browser, but publicly trusted, so nothing to configure. | 2 – 5 | +| **Service provider key pair** — `SHIBBOLETH_SERVICE_PROVIDER_CERTIFICATE` / `_PRIVATE_KEY` | SONAR | The **public part**, registered in the Resource Registry, is what the IdP encrypts the assertion to. The **private part** decrypts it. Not used to sign in the current configuration, since SONAR does not sign its requests. | 6 (encryption), 7 (decryption) | +| **Identity provider signing certificate** — `data/idp_certificates/.crt` | SWITCH | Verifying the signature of the response. A trust anchor: without it, a forged response would be accepted. | 6 | + +Two distinctions worth holding on to. + +**TLS and SAML are independent layers.** TLS protects the hop; SAML protects the +message. A perfectly valid TLS session tells you nothing about who issued the +assertion, and the SAML signature stays valid whatever happens to the transport. +They fail separately and for different reasons. + +**Public and secret are inverted between the two SAML certificates.** The +identity provider certificate is *meant* to be distributed — SWITCH publishes it +in the federation metadata, and it is legitimately versioned in this repository. +The service provider certificate is public too, but its key is not: the pair is +provisioned per environment, outside the repository, and its public part has to +be registered with SWITCH before anything works. + +## Where the identity provider certificates come from + +`data/idp_certificates/.crt` is a copy of what SWITCH publishes in the +federation metadata. That aggregate is the authoritative source: whenever an +identity provider rotates its signing key, this is where the new certificate +appears. + +| Federation | Aggregate for service providers | Identity provider entityID | Local file | +|---|---|---|---| +| Test | `https://metadata.aai.switch.ch/metadata.aaitest+idp.xml` | `https://test.eduid.ch/idp/shibboleth` | `eduidtest.crt` | +| Production | `https://metadata.aai.switch.ch/metadata.switchaai+idp.xml` | `https://eduid.ch/idp/shibboleth` | `eduid.crt` | + +Use the `+idp` aggregates rather than the legacy `metadata.aaitest.xml` and +`metadata.switchaai.xml`: they hold only identity providers, which is all a +service provider needs, and they are an order of magnitude smaller — 271 KB +against 2.5 MB for the test federation, 1.4 MB against 21 MB for production. + +To refresh a certificate, pull the `KeyDescriptor use="signing"` of the matching +`EntityDescriptor`. The repository stores it as bare base64, without the PEM +header lines and wrapped at 64 columns — `python3-saml` adds the headers itself. +Two details cost an afternoon each if missed: the file must end with a newline, +and no line may carry trailing spaces. + +Verify what you extracted before replacing anything: + +```shell +openssl x509 -inform DER -in .der -noout -subject -dates -fingerprint -sha256 +``` + +Both files currently in the repository match their published counterparts +exactly, checked by SHA-256 of the DER. + +The aggregates are themselves signed, with a certificate chaining to the +Switch edu-ID Root CA — see the [SWITCH PKI repository](https://help.switch.ch/pki/aai/). +Verifying that signature is the rigorous way to trust an extraction; fetching +over HTTPS from `metadata.aai.switch.ch` and comparing fingerprints is the +pragmatic one. + +## Configuration this flow depends on + +**In the Resource Registry** — — +where the SONAR service is administered. The same registry serves both +federations; each resource carries its own environment, so the test service and +the production one are edited separately and approved separately. + +The service must require the **private identity** together with the *edu-ID +linked affiliation* attribute. Those two go together: +`swissEduIDLinkedAffiliation` is only released with the private identity, and +requiring the private identity is also what keeps the `Issuer` constant. +Declaring the attribute alone gets the request rejected — which is exactly how +the first attempt was answered. + +The *Identity Selection* preference — "ask if personal or affiliation identity +should be used" — must stay off. It permits the affiliation identity, which is +exactly what breaks the flow. + +The **intended audiences** must be narrowed to match. The registration authority +asked for this explicitly on the second review: the other home organisation +types are to be marked as *excluded*, leaving only the private identity. The +vocabulary of `swissEduPersonHomeOrganizationType` has eight values, so eight +decisions to make: + +| Value | Meaning | +|---|---| +| `university` | University or federal institute of technology | +| `uas` | University of applied sciences, or university of teacher education | +| `hospital` | Hospital | +| `library` | Library | +| `tertiaryb` | Professional education and training college (tertiary B) | +| `uppersecondary` | Vocational or general education school, upper secondary | +| `vho` | Virtual home organisation | +| `others` | None of the above | + +Leaving them open would contradict the identity requirement: a user arriving +with a university affiliation would match an intended audience the service +cannot in fact serve, since that path releases neither the linked affiliation +nor a stable identifier. Excluding them makes the registration say what the +service actually does — everyone signs in with their private edu-ID, and +affiliations arrive as attributes rather than as an identity. + +Every change here goes through approval by the registration authority, so plan +for a round trip. Group edits rather than submitting them one at a time: a new +assertion consumer service URL and an attribute requirement, for instance, cost +one wait together and two separately. + +**In SONAR**, three settings and one file per provider: + +| | | +|---|---| +| `SHIBBOLETH_SERVICE_PROVIDER["entity_id"]` | must match the registered service | +| `SHIBBOLETH_SERVICE_PROVIDER_CERTIFICATE` / `_PRIVATE_KEY` | the key pair the assertion is encrypted to; provided per environment, never versioned | +| `SHIBBOLETH_IDENTITY_PROVIDERS[]` | `entity_id`, `sso_url` and the attribute `mappings` | +| `data/idp_certificates/.crt` | the identity provider signing certificate, published in the SWITCH federation metadata | + +## How to test + +### Give the test account an affiliation + +This is the step that is easy to miss, and it invalidates every other test until +it is done. A fresh edu-ID account has a personal identity and nothing else, so +`swissEduIDLinkedAffiliation` has nothing to carry — the attribute is then +missing from the assertion entirely, even once the Resource Registry allows it. +Two causes, one symptom, and they are easy to confuse. + +Add an organisational identity from the account itself: + +- Test federation — +- Production — + +The page offers **Add an organisation identity**, which links you to an existing +one, and **Create an organisational identity yourself**. On the test federation, +the first lists a set of demo identity providers: + +```text +Demo Home Organisation Demo Others Demo Tertiaryb +Demo Hospital Demo Partner University Demo UAS +Demo Library Demo University +``` + +That list is not arbitrary: it mirrors the `swissEduPersonHomeOrganizationType` +vocabulary documented above, one demo provider per type. + +Do not expect linking one of them to demonstrate that an excluded organisation +type is refused. With the private identity required, the affiliation chooser +never appears and the user always authenticates with their personal edu-ID, +whatever they are linked to. Linking `Demo University` simply adds another +`@` value to the released list. What the demo providers let you +vary is the **content** of `swissEduIDLinkedAffiliation`, not the identity the +assertion is issued for. + +The account used throughout this document is linked to **Demo Library**, which +yields the scope `library-demo.ch` — hence `member@library-demo.ch` and +`student@library-demo.ch`. Note that one institution produces two values: the +specific role and `member`. + +### Check the prerequisites + +Four things must hold, and each fails in its own recognisable way. + +| Requirement | How it fails when missing | +|---|---| +| `SHIBBOLETH_SERVICE_PROVIDER_CERTIFICATE` and `_PRIVATE_KEY` set in `invenio.cfg` | No SWITCHaai button at all — `get_switch_aai_providers()` returns an empty list, so the login page hides it | +| `APP_ENV=development` | The button appears but `eduidtest` is absent from it: providers flagged `dev` are filtered outside development | +| A development certificate covering the hostname, and **trusted** | Login runs to the end, then the browser warns on the return POST and the assertion is lost with the request body | +| The assertion consumer service URL registered in the Resource Registry | The identity provider refuses the request before any page is shown | + +### Run it + +```shell +APP_ENV=development uv run poe server +``` + +Open `https://sonar.ch:5000`, choose the SWITCHaai provider, and follow the four +identity provider steps. The consent page of step 5 should list *edu-ID linked +affiliations* among the attributes; if it does not, the Resource Registry +declaration is the place to look, not the code. + +### When nothing seems to change + +The three causes, in the order worth checking: + +- **The consent page no longer appears, and the attribute is still absent.** + Stored consent covers the previous, narrower attribute set. Tick the box on the + login page to revoke it, then sign in again. +- **The consent page lists the attribute but the assertion does not carry it.** + The account has no affiliation — go back to the account page above. +- **Nothing at all reaches SONAR.** Look at the browser rather than the server: + a certificate warning on the return POST swallows the assertion silently, and + the server logs show nothing because no request ever arrived. + +## If this module ever becomes costly: the OpenID Connect path + +edu-ID speaks OpenID Connect as well as SAML, from the same Shibboleth identity +provider. This section records what was established while documenting the SAML +integration, so that the option can be evaluated later without redoing the +research. **It is not a plan.** Nothing here justifies migrating today. + +### What would trigger a look + +- A certificate rotation that goes wrong, or the per-environment provisioning of + the service provider key pair becoming a recurring cost. +- SWITCH announcing a deprecation date for SAML. As of August 2026 there is + none. + +### Where things stood in August 2026 + +SWITCH publishes a governance RFC on the OIDC identity model — published +1 February 2026, final specification 21 July 2026 — whose purpose is precisely to +release **organisational identities** through OIDC, which until then carried only +the extended identity model. Its motivation is stated plainly: *“SAML is not +actively developed anymore and lacks support for use cases like mobile or +single-page applications or OAuth 2.0.”* + +There is, however, **no deprecation statement and no timeline**. The service +documentation presents both protocols as supported, and notes that OIDC support +“is currently limited compared to SAML”. Read that RFC first: it describes how +the affiliation model this module depends on maps onto OIDC claims. + +### What the integration would look like + +Discovery documents are published on both federations — +`https://login.eduid.ch/.well-known/openid-configuration` and its +`login.test.eduid.ch` counterpart. The endpoints follow the Shibboleth layout +(`/idp/profile/oidc/authorize`, `/token`, `/userinfo`, `/keyset`), not the +Keycloak one. + +`scopes_supported` advertises both `https://login.eduid.ch/authz/User.Read`, +which the attribute specification ties to `swissEduIDLinkedAffiliation`, and the +newer `https://eduid.ch/scope/userinfo.read`. Check which one SWITCH expects +before using either — the affiliations this module exists to read are reachable +as an OIDC claim through one of them. + +The integration point would be `OAUTHCLIENT_REMOTE_APPS`, alongside ORCID, using +`OAuthSettingsHelper` with explicit endpoint URLs. Three cautions: + +- **The Keycloak contrib is not reusable as is.** Despite looking like a generic + OIDC helper, it derives endpoints from Keycloak's URL scheme and fetches the + signing key from a realm URL rather than a `jwks_uri`. +- **Audience verification is off by default.** In + `contrib/keycloak/helpers.py`, `_VERIFY_AUD` defaults to `False`. Binding the + ID token to the client is the property that makes OIDC an authentication + protocol rather than a bearer-token scheme; leaving it off reproduces the + weakness OIDC was designed to fix. +- **The registration cost does not disappear.** OIDC services are registered + through the same Resource Registry, with the same approval cycle, scopes + standing in for attribute declarations. + +### The trade + +Gone: the service provider key pair to provision and rotate per environment, the +identity provider certificate tracked in `data/idp_certificates/`, and the XML +signature and encryption stack. Four certificates become client credentials — +either a client secret or, since `token_endpoint_auth_methods_supported` +advertises `private_key_jwt`, a registered public key, which trades one key pair +for another rather than removing it. + +Added: rewriting the login path, the handler and the attribute mapping; +re-registering both services; and either a period running two mechanisms or a +flag day. + +## Reproducing a capture + +Install [SAML-tracer](https://addons.mozilla.org/firefox/addon/saml-tracer/), +sign in, then export. Two cautions: + +- **The export contains the password in clear text**, in the `j_password` field + of the `e13s2` step, along with the e-mail address and the full assertion. + Treat an export as a credential: keep it out of the repository, and change the + password of any account whose capture was shared. +- Capture in a private window, or the export will be buried under unrelated + traffic from your other tabs — the original had 62 requests, of which 45 came + from an open webmail. diff --git a/sonar/modules/shibboleth_authenticator/eduid-consent.png b/sonar/modules/shibboleth_authenticator/eduid-consent.png new file mode 100644 index 000000000..8645f5108 Binary files /dev/null and b/sonar/modules/shibboleth_authenticator/eduid-consent.png differ