Nets eID Broker: where StubID differs on purpose

Every entry here is a deliberate decision, not an omission. A running instance serves the same list at GET /_stubid/v1/fidelity, read from annotations next to the code that emits each behavior, so this document and the running system cannot disagree.

The sections below explain the decisions in the order somebody meeting them needs them. The whole ledger is at the end, written out from those same annotations rather than typed up beside them, and what a recording would settle collects the questions a future sitting could close.

Client secrets are not checked

The broker refuses a wrong secret with {"error":"invalid_client"}. StubID accepts any non-empty secret for a registered client.

Why. A stub cannot know the secret an existing configuration already carries, and demanding a particular one would defeat the point of changing only the authority. A missing secret is still refused, because telling "authenticated badly" from "did not authenticate at all" is behavior worth keeping.

What this costs. A test asserting that a wrong secret is rejected passes against pre-production and fails here. If that matters to you, say so — pinning expected secrets per client is a small change.

An id_token_hint is read, not verified

At the end-session endpoint the broker checks the hint it is given. StubID reads it: any three-part token whose payload carries a sid is accepted, including one StubID never issued.

Why. The same trade as the client secret above. A test that builds a hint by hand is more likely than an attack on a stub, and refusing it would fail a test that works.

What this costs. A test asserting that a forged hint is rejected passes against pre-production and fails here.

A request object is read, not verified

The broker verifies the request parameter's signature against the client secret, HS256, and refuses a bad one with invalid_request_object. StubID reads the object and takes its parameters without checking who signed it. What it does check is that the object can be read at all: three segments, a payload that is base64url, that is JSON, that is an object, and that carries exp. Anything else earns the same refusal the broker gives.

Why. The same trade twice over, and here it is forced: the signature is over the client secret, and StubID registers no secret for a client, so there is nothing to check one against. A test that assembles an object by hand is far more likely than an attack on a stub, and refusing one would fail a test that works.

What this costs. A test asserting that a forged or wrongly-signed request object is rejected passes against pre-production and fails here. So does one asserting that an expired object is rejected: exp has to be there and is not compared against the clock, because checking a lifetime while ignoring a signature is a strange half of a check to keep.

Two decisions the recordings could not settle, taken here rather than left to be discovered:

  • The object wins over the query where both carry the same name, which is what OpenID Connect Core 6.1 says. CAP-031 cannot show it: its query and its object agree on the two names they share.
  • iss, aud, exp, iat, nbf and jti do not become request parameters. They are the JWT's own furniture rather than anything a client sent, and RFC 9101 draws the same line — an endpoint takes the object's authorization request parameters out of it. Nothing downstream reads any of the six, so the only place the difference would show is a parked session's parameter view.

An empty request= is treated as no object at all, which is what every other optional parameter here does with an empty value. Unmeasured: no probe sent one.

A pushed request really does expire. PAR answers with expires_in: 600, which is measured, and StubID holds a reference to it: past that, redeeming one earns the same error page an unknown reference always did. That the broker enforces its own number is not measured — reaching it needs a push left for ten minutes and then redeemed, which no capture step waits for — so the lifetime is a recording and its enforcement is RFC 9126 §2.2 plus the assumption that an advertised expiry means something.

Where the refusal came from. CAP-046, which pushes a request parameter that is not a JWS at all and records the 400 and the bytes that come back. That case also settled the status, which until it was taken was an inference from RFC 9126 and from CAP-019's other refusal.

Three further causes earn the identical answer and stay a measurement rather than a recording: a flipped signature byte, a random key, and a missing exp, each on two clients and two runs (what the broker does with a signed request object). Recording one would mean committing a request object signed HS256 with the client secret, and a compact JWS like that is a known-plaintext HMAC tag over the secret that signed it — an offline oracle for it, in a public repository. The manual sitting reached the same conclusion from the other direction: CAP-031 records a request object's algorithm and its segment lengths, and never its signature.

The accepted half is recorded: CAP-031's authorize URL carried client_id, response_type and request and nothing else, and the login it started completed.

Advertised but not implemented

The discovery document is served from a recording, so it advertises everything the broker does. Some of that is not implemented:

Advertised State
backchannel_authentication_endpoint (CIBA) not implemented; the endpoint answers 501, below
frontchannel_logout_supported, backchannel_logout_supported ending a session works; notifying the other clients in it does not
Request-object encryption not implemented
DPoP not implemented

Trimming the discovery document to match would be less faithful, not more: some client libraries key off metadata that is absent, and the recording is what the broker sends. The honest position is to advertise what the broker advertises and say plainly what is missing.

The backchannel authentication endpoint answers 501

CIBA is the one of those four that is a path rather than a capability, so it is the one that can be asked. POST /op/connect/ciba — and GET, which is not a method the broker takes but is the one somebody exploring will try — answers 501 with a body naming this section:

{
  "error": "not_implemented",
  "detail": "StubID does not emulate /op/connect/ciba.",
  "reason": "https://github.com/benne/stubid/blob/master/docs/brokers/neb/divergences.md#ciba"
}

It used to answer 404, which is worse than it sounds. A 404 says there is no such endpoint, when discovery has just said there is one; whoever is reading the log goes looking for a routing mistake that is not there. The status says which of the two it is, and the link says why, which is the whole of what this document is for.

Nothing about the shape above is emulated and no test should assert on it beyond the status. The broker's own answer to a CIBA request is unrecorded — reaching it needs a client provisioned for backchannel authentication, which the test clients are not.

Where a recording could not settle it

Four behaviors are implemented from the broker's documentation rather than from a recording, because reaching them needs something the unattended captures cannot do.

Behavior Why it is unrecorded
End session honoring post_logout_redirect_uri with a valid id_token_hint Needs a real id_token, which needs a completed login. The half without a hint is recorded, in CAP-044 and CAP-045: the redirect is ignored and the browser goes to the broker's own logout page.
The CPR-match refusal after three attempts Needs a fourth call inside one authenticated session. The sitting that could have recorded it spent its attempts on the earlier branches. The sentence StubID returns is the broker's documented one.
prompt=none answering login_required Needs a client with single sign-on and a session already open. The specification's answer is used.
cprNumberMatch being a JSON boolean No capture reached a successful match, so the type is the pre-production swagger's. Worth doubting: every value on this broker's userinfo endpoint is a string, including two that are plainly booleans.

Each is marked in the fidelity ledger with the provenance it actually has, so GET /_stubid/v1/fidelity does not claim more than was checked.

What a recording would settle

Each of these names the sitting that would answer it, so the next capture session has a list to work from rather than a memory of what was doubted.

What What would settle it
BrokerState.PushRequest That the lifetime is 600 seconds is measured; that the broker enforces it is not. Reaching that needs a push left for ten minutes and then redeemed, which no capture step waits for. RFC 9126 2.2 says a request_uri expires, and the authorize endpoint already answered 'Unknown or expired request_uri' for a reference it could not find.
Tokens.TransactionActions The string-or-array form is recorded, and so is each action on its own. Two things are not. Deriving mitid.cpr_match from the ssn scope is inferred - CAP-021 asked for ssn and matched a CPR in the same sitting, so it cannot say which put the action there. And no recording carries more than two actions, so the order between mitid.cpr_match and mitid.transaction_signing is unobserved: a signing login that also asks for ssn would settle it.
Tokens.TransactionToken One slot in this sequence is a guess. The ssn consent pair and the six transaction-text members both land after mitid.geo_ip_distance_km, and no recording carries both: CAP-021 has the consent pair without a text, CAP-031 has the text without the ssn scope. The consent pair comes first here because that is the slot CAP-021 recorded for it. A signing login that also asks for ssn would settle it. Everything else in the order is forced by the three recordings together, which merge without a conflict.
Tokens.UserInfo The same unrecorded slot as the transaction token's: the ssn consent pair and the transaction text's type and digest both land after mitid.geo_ip_distance_km, and no recording carries both. A type standing here without a digest beside it is also StubID's own - it is what a text this cannot decode leaves behind, and CAP-031, the only recording, sent a text that decodes.

Transaction signing

StubID issues a transaction token when the request asks for the transaction_token scope, with a transaction_token_ocsp_resp beside it. The pair is never split, because no recorded body splits it.

idp_params is read, and both texts it can carry reach the places the recordings put them. A reference_text comes back whole in the transaction token and at the userinfo endpoint, the way CAP-022 recorded. A transaction_text comes back as six members under both spellings, with a digest StubID computes, and as a type and that digest — without the text — at the userinfo endpoint, the way CAP-031 recorded. The request parameter CAP-031 arrived through is read on both the authorize and the pushed path, read rather than verified, for the reason above.

A transaction text is not gated on a signed request. Whether the broker would take one in a plain query is unmeasured: the claim that it takes one only inside a request object comes from vendor prose deleted in June 2025, no probe ever sent one unsigned, and CAP-022 shows unsigned idp_params being accepted in a plain query. StubID accepts a transaction text however it arrives, because refusing one would enforce a restriction nobody has demonstrated and would fail a test that may well work against pre-production. mitid.transaction_signing follows the text rather than the transport for the same reason.

Three things about the text are StubID's own, because CAP-031 sent a well-formed one and is the only recording there is:

  • A text that cannot be decoded keeps its members and loses its digest. Four members instead of six in the transaction token, and at the userinfo endpoint a mitid.transaction_text_type standing without a mitid.transaction_text_sha256 beside it. The alternative is an empty 500 out of the token endpoint on a value the client controls, and that is the one answer the broker never gives. What the broker does here is unrecorded.
  • A text with no type gets no type. Each of the three values is conditional on its own, so a client that sends only a text gets four members and no invented text. Emitting a JSON null would break the userinfo endpoint's every-value-is-a-string invariant; emitting "text" would be StubID answering a question nobody asked it.
  • Both base64 alphabets are accepted, and whitespace is refused rather than skipped. The recorded text sits in the intersection of standard and URL-safe base64 — no +, /, - or _, and a length that is a multiple of four — so no recording says which the broker parses. Whitespace is the half with a trap in it: Convert.FromBase64String skips it, which changes the answer without saying so, since a value with characters removed still decodes and decodes to different bytes. Refused, such a text keeps its members and loses its digest like any other that will not decode.

The digest itself is not a choice. It is base64 of SHA-256 over the decoded bytes, standard alphabet and padded, recomputed from CAP-031 and matched. The digest over the base64 as sent — the answer a stub reaches for first — is a different value, and there is a test that computes both so a failure says which one was emitted.

An earlier version of this file said the text claims needed a signtext_api scope that only the broker's staff could grant. That name has no source: not in the vendor documentation, not anywhere public, and not in this repository outside the probe that used it. The capture cited for it, CAP-016, settled a grant-type refusal rather than a scope. CAP-031 settles it from the other side — the text claims came back on the same client and the same granted scope CAP-022 had, with nothing added to reach them.

StubID's login page shows the transaction text and nothing else

The broker's authorize page is built out of the request. Its MitID widget is headed Godkend hos the relying party's registered display name, and on a signing request the transaction text stands in a panel beside the widget. StubID's login page carries the text — decoded, on StubID's own page rather than behind a simulated authenticator, which is where the broker put it too (what the screens showed). It carries nothing else the request sent: no client name, no client_id, none of MitID's furniture.

Why the text and not the rest. A person is being asked to approve something, and the text is what they are approving. Everything else on the broker's page is the broker's — a widget, a brand, a registered display name — and reproducing it would put someone else's trade dress on an emulator, which is what the login page already gives as the reason a page that looked convincing is a page someone can be fooled by. The client's name is a separate matter and not a decision: StubID registers no display name for a client at all, Client being a client id, its response types and an organization.

The text is escaped whether it says text or html. The broker parses an html transaction text against a tag allowlist and renders it. StubID escapes both, because this is the first client-controlled string the page has ever shown and it is shown in a window a browser has just been redirected to from a real authorize request. A test asserting that markup in an html transaction text renders as markup passes against pre-production and fails here.

The text reaches the page from the parsed request rather than from the query the session parked with, and that placement is the whole of it: after a push the browser arrives carrying a client id and a request reference, and on a signed request the parameters are inside a JWS. A page fed from the raw query works on a plain GET and goes blank on the other two arrival shapes, with no error to say why.

A parked login resumes in one hop, at StubID's own page

Approving on /op/Login returns the browser to the client with a code. The broker gets there by a different route: it parks by redirecting to /op/Account/Login?ReturnUrl=%2Fop%2Fconnect%2Fauthorize%2Fcallback%3F…, so approving re-enters its own authorize pipeline at an internal callback path and the browser is carried onwards from there. StubID parks at /op/Login?session=… and completes in place.

Why not the two-hop shape. Four reasons, and the first is decisive:

  • It is not recorded. That ReturnUrl is a Location header from a day-zero probe, and the capture session says plainly that every existing fixture stops at the 302 — the page sequence itself is captured nowhere. Building it means inventing a flow in a repository whose rule is that emulated behavior cites a recording.
  • The ReturnUrl is the raw-query replay, and that replay cannot work here. A form POST leaves no query to replay, and a pushed request's reference is consumed on the way in, so replaying it earns "Unknown or expired request_uri". Reproduce the shape faithfully and you reproduce a login that resumes on two of the four ways it can arrive.
  • The page path already differs, deliberately and for the reason above. Copying the broker's internal routing while refusing its page is the wrong half.
  • A client cannot see it. The hop is between the broker's own endpoints; what reaches the client is a redirect to its redirect_uri either way.

What this costs. A test that asserts on the broker's intermediate URL sees StubID's instead. Nothing that reads the callback can tell the difference.

An approval nobody collects expires. Approving stops the parked deadline mattering — that is what lets the person who clicks at the last moment keep their approval — so an approved login gets a second window of its own, the same five minutes, measured from the decision. Walk away after approving and come back an hour later and the client is told mitid_timeout rather than handed an hour-old code. Unrecorded: no capture ever approved a login and then left it.

A response_mode nothing supports is answered two ways. Approving such a request lands on StubID's error page, because there is no mode to answer in; aborting it still redirects to the client with a query. Nothing validates the parameter on the way in and no recording says what the broker does with one, so this is an asymmetry that is written down rather than resolved.

What is still missing. Nothing returns the browser without a browser: an approval made through the control API is collected when the browser next asks for the page, and a caller with no browser at all gets a decided session and no code, which is what queueing an outcome is for.

The OCES3 certificate chain

The broker signs its transaction token with a certificate issued by a Danish state CA and returns an OCSP response alongside it. StubID now returns one too, and this is what is different about it.

The shape is reproduced, and it was not guessed. The three recorded responses are decoded and asserted by OcspResponseContractTests, they agree on all of it, and StubID writes the same structure: a successful basic response holding exactly one answer, good, whose CertID names the transaction-signing certificate by the SHA-1 of its issuer's name and its serial number; signed ECDSA-with-SHA-256 by a responder whose own certificate travels inside the response, with enhanced key usage OCSP signing and nothing else and carrying id-pkix-ocsp-nocheck; no nonce and no response-level extensions at all; and one non-critical archive-cutoff extension on the answer.

Four things differ. The first three cannot be closed by writing more code; the fourth is a choice:

The responder is self-signed. The broker's is a delegated responder issued by the same state CA as the certificate it answers about. StubID has no CA — every certificate it makes is self-signed with CA=false, and .NET refuses such a certificate as an issuer — so two relations that hold on every recording do not hold here: the responder was not issued by the certificate's own CA, and the CertID's issuerKeyHash is not an Authority Key Identifier, because a self-signed certificate made here carries none. OcspWriterTests asserts both absences, so nobody reads the recordings and expects them.

producedAt is now. The broker serves an answer it already had — CAP-031's is three and a half minutes before the response carrying it — where StubID mints one per response. Caching to reproduce the staleness would make the output depend on how long the instance had been running, which is the opposite of what a test wants.

The archive cutoff is StubID's own beginning. The recorded cutoff is a fixed 2021 date, the same on recordings taken days apart, because it says how far back the CA keeps answers. StubID uses the moment its own signing certificate began, since nothing before that can be asked about.

nextUpdate is a day out, not five years. This one is reproducible and deliberately is not. The recorded responder sets nextUpdate one second before its own certificate expires, so with StubID's five-year responder the answer would claim to be good until 2031 and a client that caches it would never ask again. A day is the conventional interval and keeps the exchange observable. One line — responder.NotAfter.AddSeconds(-1) — would match the recording exactly if a test ever needs it to.

A client that resolves a signing key by kid from the published key set — which is what the broker's own verification guide tells you to do — works against both, and CAP-031 is the first capture where that path ran end to end against the broker: every token verified under the key its kid resolved to in the key set as it stood that day. A client that validates the certificate chain, or that checks the OCSP response against a trusted issuer, works against pre-production and fails here. There is no fix for that; it is disclosed rather than papered over.

What is not reproduced

Infrastructure that belongs to the broker's hosting rather than its protocol: the server header, x-neb-site, HSTS and CSP headers, the wording of the error page, and response timing. StubID emits an X-StubID-Emulator header of its own so an instance cannot be mistaken for the real thing.

The whole ledger

Every annotation in the two assemblies that carry them, in the order a reader needs: what is not reproduced at all, then what diverges on purpose, then what rests on documentation, and only then what a recording confirmed. The same list a running instance serves at GET /_stubid/v1/fidelity, and the same order the admin pages put it in.

What How close On what evidence Because
Endpoints.Ciba OutOfContract, NotEmulated fixtures/neb/pp/CAP-001 why
BrokerState.EndsSession Exact, Divergent - why
BrokerState.IsKnownClient Exact, Divergent fixtures/neb/pp/CAP-014 why
Endpoints.Complete Shape, Divergent - why
Endpoints.TransactionTextPanel Shape, Divergent - why
OcspWriter.Good Shape, Divergent fixtures/neb/pp-session/CAP-021/token/response.raw, fixtures/neb/pp-session/CAP-022/token/response.raw, fixtures/neb/pp-session/CAP-031/token/response.raw why
StubId.Server.RequestObject Shape, Divergent - why
StubIdApplication.AnnounceTheEmulator Exact, Divergent - why
Tokens.TransactionTokenOcspResponse Shape, Divergent fixtures/neb/pp-session/CAP-021/token/response.raw, fixtures/neb/pp-session/CAP-022/token/response.raw, fixtures/neb/pp-session/CAP-031/token/response.raw why
BrokerState.PushRequest Exact, Assumed - -
Tokens.TransactionActions Exact, Assumed fixtures/neb/pp-session/CAP-021/token/transaction_token.payload.json, fixtures/neb/pp-session/CAP-022/token/transaction_token.payload.json, fixtures/neb/pp-session/CAP-031/token/transaction_token.payload.json -
Tokens.TransactionToken Exact, Assumed - -
Tokens.UserInfo Exact, Assumed - -
CprMatch.Exceeded Exact, DocsConfirmed Unrecorded: reaching it needs a fourth call inside one authenticated session. -
Endpoints.Matched Exact, DocsConfirmed The pre-production swagger. Unrecorded: no capture reached a successful match. -
Endpoints.SilentLoginImpossible Exact, DocsConfirmed OpenID Connect Core 3.1.2.6. Unrecorded: needs an established SSO session. -
StubId.Wire.Cpr Shape, DocsConfirmed https://www.cpr.dk/media/12068/erstatningspersonnummerets-opbygning.pdf -
BrokerState.PushRequest Exact, VerifiedLive docs/research/signed-requests.md -
Endpoints.EndSession Exact, VerifiedLive fixtures/neb/pp/CAP-044, fixtures/neb/pp/CAP-045 -
JwsWriter.Sign Exact, VerifiedLive fixtures/neb/pp-session/CAP-024/token/id_token.header.json -
RequestGrammar.Fault Exact, VerifiedLive fixtures/neb/pp/CAP-009, fixtures/neb/pp/CAP-040, fixtures/neb/pp/CAP-043 -
RequestGrammar.IdentityProviderParameters Exact, VerifiedLive fixtures/neb/pp/CAP-010/response.head, fixtures/neb/pp-session/CAP-022/callback/meta.json -
RequestObject.Fault Exact, VerifiedLive fixtures/neb/pp/CAP-046, docs/research/signed-requests.md -
RequestObject.TryMerge Exact, VerifiedLive fixtures/neb/pp-session/CAP-031/callback/meta.json, fixtures/neb/pp-session/CAP-031/callback/request_object.payload.json -
StubId.Wire.JwksWriter Exact, VerifiedLive fixtures/neb/pp/CAP-002 -
StubId.Wire.Pkce Exact, VerifiedLive fixtures/neb/pp/CAP-001 -
StubId.Wire.SigningKey Exact, VerifiedLive fixtures/neb/pp/CAP-002 -
Tokens.IdToken Exact, VerifiedLive fixtures/neb/pp-session/CAP-024/token/id_token.payload.json -
Tokens.Subject Exact, VerifiedLive fixtures/neb/pp-session/CAP-029/token/id_token.payload.json -
Tokens.TransactionText Exact, VerifiedLive fixtures/neb/pp-session/CAP-031/token/transaction_token.payload.json -
Tokens.TransactionToken Exact, VerifiedLive fixtures/neb/pp-session/CAP-021/token/transaction_token.payload.json, fixtures/neb/pp-session/CAP-022/token/transaction_token.payload.json, fixtures/neb/pp-session/CAP-031/token/transaction_token.payload.json -
Tokens.UserInfo Exact, VerifiedLive fixtures/neb/pp-session/CAP-021/userinfo/response.raw, fixtures/neb/pp-session/CAP-022/userinfo/response.raw, fixtures/neb/pp-session/CAP-031/userinfo/response.raw -
Tokens.UserInfoToken Exact, VerifiedLive fixtures/neb/pp-session/CAP-024/token/userinfo_token.payload.json -