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,nbfandjtido 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_typestanding without amitid.transaction_text_sha256beside 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 JSONnullwould 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.FromBase64Stringskips 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
ReturnUrlis aLocationheader 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
ReturnUrlis 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_urieither 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 | - |