Built-in OpenID Connect identity provider. The handler is fully spec-compliant for Authorization Code + PKCE, exposes discovery + JWKS at the conventional /.well-known paths, and signs id_token / access_token with RS256.
EU Wallet Layer 1 claim relay (Release 18, 2026-06-10).
After a successful OID4VP presentation, the user's verifiable credentials are merged
into BOTH the id_token mint and the userinfo response, with
a profile-precedence alias map (birthdate←birth_date, email←email_address,
name←full_name, given_name←first_name, family_name←last_name).
Top-level OIDC standard claims appear at the root; a namespaced "vc" claim carries
the full disclosed attribute set. Downstream RPs (Nextcloud, WordPress, custom apps) receive
the relayed claims without any wallet-specific code.
See EU Wallet integration →
Two URL shapes, one handler. Every endpoint below is reachable in either form — the conventional /oauth2/v1/<name> path (used by most commercial OIDC providers and what discovery advertises), or the legacy /oidc.ashx?action=<name> form. RP libraries that follow discovery automatically use the /oauth2/v1/* shape and need no special configuration.
Form POST that authenticates the visitor using the same HA1 (MD5(user:realm:password)) as the SIP credentials store. Computes HA1 client-side so plaintext passwords never reach the IdP. When the return URL points back to ?action=authorize, the code is minted directly — no cookie is set.
Request
Body fields: user, ha1 (32 hex), return (optional URL).
Response
Either a 302 redirect with ?code=… appended to return, or JSON { ok: true, code: "…" }.
Errors
400 / 401 { error: "invalid_credentials" }. 429 if IP exceeded 10 attempts in the last 60 s.
HA1 comparison is constant-time. The login is stateless: no session cookie is set on the IdP origin.
RFC 6749 token endpoint. Exchanges either an authorization code (with PKCE verifier) or a refresh token for a fresh id_token, access_token and rotated refresh_token.
Request
Form / JSON body: grant_type (authorization_code, refresh_token, or urn:ietf:params:oauth:grant-type:jwt-bearer), code, redirect_uri, code_verifier, client_id, client_secret (confidential clients only), refresh_token, assertion (JWT-bearer grant only).
The JWT-bearer grant (RFC 7523) lets a wallet integrator exchange an SSO assertion (typ=sso) from vp-response directly for an access_token, skipping the full Authorization Code + PKCE choreography. See the EU Wallet integration walkthrough →
OpenID Connect RP-Initiated Logout 1.0. Clears the IdP-side SSO assertion and bounces the browser back to post_logout_redirect_uri if it’s registered for the client.
Equivalent legacy form: /oidc.ashx?action=userinfo. Discovery advertises the /oauth2/v1/userinfo path so off-the-shelf RP libraries pick it up automatically.
RFC 7662 token introspection. Submit any token issued by this IdP — access_token, id_token, or refresh_token — and find out whether it’s still active, who it belongs to, and when it expires.
Request
Form / JSON body: token (required), token_type_hint (optional — access_token, id_token or refresh_token), client_id (required only when introspecting a token that was issued to a confidential client), client_secret (then required).
For an inactive / unknown / expired token — per RFC 7662 §2.2 the only field returned is active:
{"active":false}
Verified 2026-06-05 — POST token=bogus → 200 {"active":false}.
Errors
400 if token is missing. 401 if the named confidential client’s secret fails to verify. Never a 4xx for “token unknown” — that returns 200 {active: false} by spec.
Useful for resource servers that want to defer token-validation logic to the IdP instead of verifying JWT signatures themselves. Note: for high-traffic resource servers, local JWT verification using the JWKS is usually faster.
Build stamp, tenant identity, and live EU Wallet verifier counters. Handy as a liveness probe and a quick way to spot the OID4VP abandonment rate without parsing logs.
FIDO2 / WebAuthn passkey sign-in — step 1. Mints a server-side challenge and returns a fully-formed PublicKeyCredentialRequestOptions JSON document that the browser feeds straight into navigator.credentials.get({ publicKey }).
Request
JSON body, optional {"username": "alice"}. Omit username for an account‑picker / usernameless sign‑in: the browser shows all discoverable passkeys for the RP ID and the user picks one. No bearer required — the passkey itself is the authentication factor.
Passkeys are tenant-scoped: a credential registered on phone.codeb.io will not authenticate on a different tenant host. RP ID never crosses domain boundaries.
FIDO2 / WebAuthn passkey sign-in — step 2. Receives the signed assertion from navigator.credentials.get(), verifies it (rpIdHash, signature against the stored COSE public key, signature counter regression check, user‑present flag), and mints the same SSO assertion shape as a successful password login.
The sso_assertion is a short‑lived JWT the browser stores in sessionStorage and exchanges for an access token at the /token endpoint, exactly as the password flow does. Signed claims include amr: ["hwk", "user"] and acr: "urn:codeb:acr:hwk-mfa", so resource servers can require strong authentication for sensitive operations.
400{ "error": "unknown_credential" } — credentialId doesn't belong to any user on this tenant.
400{ "error": "verification_failed" } — signature mismatch, counter regression, rpIdHash mismatch, or stale challenge.
Example
Driven by the browser, not by curl — navigator.credentials.get() produces the signed payload above and the page POSTs it. See loginpasskey.html for a complete worked example.
Verified 2026-06-11 against live (Release 25).
After successful verification the server bumps the stored signature counter, refuses any future assertion with a counter ≤ the stored one (clone‑detection), and records the credential's last‑used timestamp for the account page.
Self‑service password recovery — step 1. The user supplies their email; the server emails them a one‑time signed token they paste into recover.html to set a new password.
Request
Form‑encoded body: email=user@example.com. No bearer required. JSON body with the same field is also accepted.
Response
{
"ok": true,
"message": "If a matching account exists, a recovery email has been sent."
}
The response is the same 200 envelope regardless of whether the email matches a user or not — no enumeration. Operators who tail the server log can see the actual decision; the client sees a uniform reply.
What happens server-side when the email matches
Mint a JWT with typ=recover, sub=<username>, iss=<tenant>, 15-minute TTL, fresh JTI.
Build a deep link: https://<tenant>/recover.html?token=<jwt>.
Drop a .eml into the configured pickup directory (WebPhone:Mail:PickupDir) for IIS SMTP to deliver.
Record the jti in the per‑tenant rate‑limit table so a second start within the rate‑limit window is suppressed.
Rate limits
Per‑email: 1 start per minute. Per‑IP: 5 starts per minute. Both windows are swept every 60 s by SweepExpired.
Verified 2026-06-11 against live (Release 25) — same 200 envelope on empty, malformed, unknown, and matching emails.
Privacy by design: even an attacker who scrapes this endpoint with a leaked email list cannot tell which addresses are registered. The only signal is the email Inbox, which they can't see.
Self‑service password recovery — step 2. The user clicks the link in their email (which lands on recover.html), the page reads the token from the URL and asks for a new password. The browser computes HA1 = md5(sub:realm:newpwd) client‑side and POSTs to this endpoint — the cleartext password never reaches the server.
400{ "error": "token_already_used" } — same JTI replayed.
400{ "error": "weak_secret" } — supplied HA1 is the all‑zeros sentinel.
Server-side verification chain
JWT signature verified against the tenant's RSA public key (RS256).
exp, iss, typ=recover all checked.
jti atomically claimed in _usedRecoverJtis (ConcurrentDictionary.TryAdd). A second submit of the same token loses the race and gets token_already_used.
HA1 format check (32 lowercase hex chars) and weak‑secret rejection.
UpdateUserHa1Atomic rewrites the tenant credentials JSON via File.Replace with a rolling backup.
Browser‑side HA1 follows the same SIP ‑digest hash format CodeB stores natively, so a single hash works for OIDC sign‑in, REST API key‑exchange, and direct SIP REGISTER — no plaintext fan‑out.
Passwordless sign-in — step 1. The user supplies their email; the server emails them a one‑time signed token they click to sign in without a password. Use this when you want users to log in without typing a credential at all.
Request
Form‑encoded body: email=user@example.com. No bearer required. JSON body with the same field is also accepted.
Response
{
"ok": true,
"message": "If an account with that email exists, you'll receive a sign-in link shortly."
}
The response is the same 200 envelope regardless of whether the email matches a user or not — no enumeration. Operators who tail the server log can see the actual decision; the client sees a uniform reply.
What happens server-side when the email matches
Mint a JWT with typ=maglink, sub=<username>, iss=<tenant>, 15-minute TTL, fresh JTI.
Build the link: https://<tenant>/login.html?maglink=<jwt>.
Drop a .eml into WebPhone:Mail:PickupDir for IIS SMTP to deliver.
Stamp _maglinkEmailRate so a second request within the rate window is suppressed.
Rate limits
Per‑email: 1 start per minute. Per‑IP: 5 starts per minute (shared budget with the login endpoint). Both windows are swept every 60 s by SweepExpired.
Released 2026-06-11 in OIDC_BUILD 2026-06-11-comino-maglink-roleredir.
Privacy by design: scraping this endpoint with a leaked email list reveals nothing — the only signal is the email inbox, which the attacker cannot see. Pairs naturally with recover-start: same envelope, same rate‑limit posture, same email pipeline.
Passwordless sign-in — step 2. The user clicks the link in their email, which lands on login.html?maglink=<token>; the page auto‑POSTs the token here on load. Returns the same shape as a successful password login — the rest of the redirect/SSO machinery is unchanged.
Request
Form‑encoded body: token=<jwt> (required), return=<return-url> (optional). Any same‑origin return is honoured exactly like in the password login.
The minted ID token / access token carry amr: ["user"] and acr: "urn:codeb:acr:email-link" — both clearly distinguishable from a password sign-in (amr: ["pwd"]) so resource servers can require step‑up auth for sensitive operations.
Errors
400{ "error": "invalid_request", "error_description": "token is required" }
Released 2026-06-11 in OIDC_BUILD 2026-06-11-comino-maglink-roleredir.
Browser flow: the user typically never sees this endpoint directly. login.html detects ?maglink=<token> on page load, dims the password form, calls this endpoint, then follows the standard role-aware redirect (admin → /admin.html, else → /account.html).
Need an admin endpoint? Admin-only and OIDC Bearer-gated routes
are documented inside the admin UI itself (visible only to signed-in admins
on this host). The public API set on this page is the surface you can
integrate against without provisioning a CodeB user.