Public API

/oidc.ashx · public API

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.

GET /.well-known/openid-configuration #

RFC 8414 / OpenID Connect Discovery 1.0 metadata document. Lists the issuer, endpoint URLs, supported response types, claims and scopes.

Request

No parameters.

Response

Standard JSON document (verified 2026-06-05):

{
  "issuer": "https://phone.codeb.io",
  "authorization_endpoint":  "https://phone.codeb.io/oauth2/v1/authorize",
  "token_endpoint":          "https://phone.codeb.io/oauth2/v1/token",
  "userinfo_endpoint":       "https://phone.codeb.io/oauth2/v1/userinfo",
  "revocation_endpoint":     "https://phone.codeb.io/oidc.ashx?action=revoke",
  "introspection_endpoint":  "https://phone.codeb.io/oauth2/v1/introspect",
  "jwks_uri":                "https://phone.codeb.io/.well-known/jwks.json",
  "end_session_endpoint":    "https://phone.codeb.io/oauth2/v1/logout",
  "response_types_supported": ["code"],
  "subject_types_supported": ["public"],
  "id_token_signing_alg_values_supported": ["RS256"],
  "scopes_supported": ["openid","profile","email","groups","phone","address"],
  "token_endpoint_auth_methods_supported": ["none","client_secret_post"],
  "claims_supported": ["sub","iss","aud","exp","iat","auth_time","nonce","amr","acr",
                       "name","preferred_username","email","email_verified",
                       "phone_number","phone_number_verified","address","locale","role","groups",
                       "given_name","family_name","middle_name","birthdate","gender",
                       "nationality","vc"],
  "acr_values_supported": ["urn:codeb:acr:pwd","urn:codeb:acr:hwk","urn:codeb:acr:hwk-mfa",
                           "urn:codeb:acr:mfa","eudi:pid:high","eudi:pid:substantial",
                           "urn:codeb:vc:member"],
  "amr_values_supported": ["pwd","hwk","mfa","swk","otp"],
  "vp_formats_supported": [],
  "code_challenge_methods_supported": ["S256"],
  "grant_types_supported": ["authorization_code","refresh_token","urn:ietf:params:oauth:grant-type:jwt-bearer"]
}

Example

curl https://phone.codeb.io/.well-known/openid-configuration
Also reachable as /oidc.ashx?action=discovery.

GET /.well-known/jwks.json #

RFC 7517 JSON Web Key Set. Contains the RSA public key used to verify RS256-signed id_token / access_token JWTs issued by this IdP.

Request

No parameters.

Response

{
  "keys": [
    { "kty":"RSA","use":"sig","alg":"RS256",
      "kid":"<current-kid>","n":"<modulus-b64u>","e":"AQAB" }
  ]
}

Example

curl https://phone.codeb.io/.well-known/jwks.json
Includes both the active key and any rotation-window predecessor so old tokens still verify until they expire. Cache for at most 24 h.

GET /oidc.ashx?action=authorize #

Authorization Code flow entry point with PKCE. Public clients (browsers, native apps) must use code_challenge + code_challenge_method=S256.

Request

Query parameters: response_type=code, client_id, redirect_uri (byte-for-byte registered), scope (default openid), state, nonce, code_challenge, code_challenge_method=S256. Optional fast-path: cp_v2_assertion=<jwt> issued by an already-signed-in same-origin session.

Response

302 redirect to redirect_uri?code=…&state=… on success, or to /login.html?return=… if the visitor isn't signed in yet.

Errors

400 with JSON {error, error_description} on unknown client, invalid redirect URI, missing PKCE, or unsupported challenge method.

Authorization codes are single-use and live 60 seconds.

POST /oidc.ashx?action=login #

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.

POST /oidc.ashx?action=token #

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 →

Response

{
  "id_token": "<rs256-jwt>",
  "access_token": "<rs256-jwt>",
  "refresh_token": "<opaque>",
  "expires_in": 3600,
  "token_type": "Bearer"
}

Errors

400 { error: "invalid_grant" } on code reuse, PKCE mismatch, expired code, unknown client. 401 on confidential-client secret mismatch.

Access tokens last 1 hour. Refresh tokens last 4 hours and rotate on every use.

GET /oidc.ashx?action=end_session #

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.

Request

Optional query: id_token_hint, post_logout_redirect_uri, state.

Response

302 to either the registered post-logout URI or /login.html.

Errors

400 if post_logout_redirect_uri isn’t in the client’s registered allow-list.

GET /oauth2/v1/userinfo #

OIDC userinfo endpoint. Returns the canonical claims about the user whose access_token is presented. Requires a valid Bearer issued by this IdP.

Request

HTTP header Authorization: Bearer <access_token>. No body.

Response (verified 2026-06-05)

{
  "sub": "alice",
  "role": "admin",
  "groups": ["admin"],
  "preferred_username": "alice"
}

Additional claims (name, email, email_verified, phone_number, address) appear when the user record has them set and the token’s scope includes them.

Errors

401 invalid_token on missing / malformed / expired Bearer:

{"error":"invalid_token","error_description":"Bearer token required"}

Example

$ curl -H "Authorization: Bearer $ACCESS" https://phone.codeb.io/oauth2/v1/userinfo
{"sub":"alice","role":"admin","groups":["admin"],"preferred_username":"alice"}
Equivalent legacy form: /oidc.ashx?action=userinfo. Discovery advertises the /oauth2/v1/userinfo path so off-the-shelf RP libraries pick it up automatically.

POST /oidc.ashx?action=revoke #

RFC 7009 token revocation. Useful when a user logs out of a confidential RP and you want to invalidate the refresh token immediately.

Request

Body: token, optional token_type_hint=access_token|refresh_token, client_id, client_secret (confidential clients).

Response

200 { "ok": true } — per the RFC, succeeds even if the token is already invalid.

Errors

400 on unknown / mis-authenticated client.

POST /oauth2/v1/introspect #

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).

Response

For an active token (RFC 7662 §2.2):

{
  "active": true,
  "token_type": "Bearer",
  "client_id": "<client>",
  "sub": "<user>",
  "scope": "openid profile email",
  "iss": "https://phone.codeb.io",
  "aud": "<client>",
  "exp": 1717248000,
  "iat": 1717244400
}

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=bogus200 {"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.

GET /oidc.ashx?action=ping #

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.

Request

No params.

Response

{
  "ok": true,
  "build": "2026-06-10-gozo-layer1-vc-relay-id-token",
  "tenant": "phone.codeb.io",
  "now": 1780995196,
  "vp_started": 2,
  "vp_completed": 2,
  "vp_abandoned": 0,
  "vp_pending_or_inflight": 0
}

Fields

  • oktrue when the OIDC handler is initialised.
  • build — handler build version. Format YYYY-MM-DD-slug; bumped on every meaningful change.
  • tenant — tenant the request resolved to (multi‑tenancy by domain).
  • now — current server time, seconds since epoch.
  • vp_started / vp_completed — counters of OID4VP sessions opened and completed since service start.
  • vp_abandoned — counter of sessions that timed out without a wallet response.
  • vp_pending_or_inflight — gauge of sessions currently waiting on a wallet.

Verified 2026-06-09.

Example

curl https://phone.codeb.io/oidc.ashx?action=ping
Full EU Wallet verifier endpoint set (vp-start, vp-request, vp-response, verifier-metadata) is documented separately at eu-wallet-api.html.

POST /oidc.ashx?action=passkey-signin-start #

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.

Response

{
  "challenge":        "<base64url>",
  "rpId":             "phone.codeb.io",
  "timeout":          300000,
  "userVerification": "required",
  "allowCredentials": [],
  "session":          "<opaque-session-id>"
}

Fields

  • challenge — base64url-encoded random bytes. Single‑use, expires when the session expires.
  • rpId — the tenant's hostname (multi‑tenancy by domain). Passkeys are scoped to this RP ID.
  • timeout — milliseconds the browser will wait for the authenticator before giving up.
  • userVerification — always "required" — the user must prove presence (TouchID / FaceID / Windows Hello / hardware token PIN).
  • allowCredentials — usually [] for the account‑picker flow. Populated when a specific username is named.
  • session — opaque pointer the browser echoes back in passkey-signin-finish. The server uses it to bind the challenge to the response.

Example

curl -X POST -H "Content-Type: application/json" \
  -d '{}' \
  https://phone.codeb.io/oidc.ashx?action=passkey-signin-start

Verified 2026-06-11 against live (Release 25).

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.

POST /oidc.ashx?action=passkey-signin-finish #

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.

Request

{
  "session":         "<from-step-1>",
  "credentialId":    "<base64url>",
  "rawId":           "<base64url>",
  "type":            "public-key",
  "response": {
    "clientDataJSON":     "<base64url>",
    "authenticatorData":  "<base64url>",
    "signature":          "<base64url>",
    "userHandle":         "<base64url> (optional)"
  }
}

Response

{
  "ok":            true,
  "redirect":      "/admin.html",
  "user":          "alice",
  "role":          "admin",
  "sso_assertion": "<short-lived-jwt>",
  "sso_max_age":   600
}

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.

Errors

  • 400 { "error": "missing_credentialId" } — payload incomplete.
  • 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.

POST /oidc.ashx?action=recover-start #

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

  1. Mint a JWT with typ=recover, sub=<username>, iss=<tenant>, 15-minute TTL, fresh JTI.
  2. Build a deep link: https://<tenant>/recover.html?token=<jwt>.
  3. Drop a .eml into the configured pickup directory (WebPhone:Mail:PickupDir) for IIS SMTP to deliver.
  4. 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.

Example

curl -X POST -H "Content-Type: application/x-www-form-urlencoded" \
  -d 'email=alice@example.com' \
  https://phone.codeb.io/oidc.ashx?action=recover-start

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.

POST /oidc.ashx?action=recover-finish #

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.

Request

Form‑encoded body: token=<jwt>&new_ha1=<32-lowercase-hex>.

Response

{ "ok": true, "redirect": "/login.html?recovered=1" }

The login page shows a green “Password reset. Sign in below with your new password.” banner when ?recovered=1 is present.

Errors

  • 400 { "error": "invalid_request", "error_description": "token and new_ha1 are required" }
  • 400 { "error": "invalid_request", "error_description": "new_ha1 must be 32 lowercase hex chars (MD5 digest)" }
  • 400 { "error": "invalid_token", "error_description": "signature failed | expired | wrong typ | wrong iss" }
  • 400 { "error": "token_already_used" } — same JTI replayed.
  • 400 { "error": "weak_secret" } — supplied HA1 is the all‑zeros sentinel.

Server-side verification chain

  1. JWT signature verified against the tenant's RSA public key (RS256).
  2. exp, iss, typ=recover all checked.
  3. jti atomically claimed in _usedRecoverJtis (ConcurrentDictionary.TryAdd). A second submit of the same token loses the race and gets token_already_used.
  4. HA1 format check (32 lowercase hex chars) and weak‑secret rejection.
  5. UpdateUserHa1Atomic rewrites the tenant credentials JSON via File.Replace with a rolling backup.

Example

curl -X POST -H "Content-Type: application/x-www-form-urlencoded" \
  -d 'token=eyJ...&new_ha1=09e74a4ce3a0a37b7d9ee3e9b7e0c4aa' \
  https://phone.codeb.io/oidc.ashx?action=recover-finish

Verified 2026-06-11 against live (Release 25).

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.

POST /oidc.ashx?action=sso-mint #

Silent same‑origin SSO. Submit the short-lived sso_assertion a successful /login, passkey-signin-finish, magic-link-finish or vp-response handed the browser, plus a /authorize return URL, and receive a fresh authorization code already wrapped in a 302-ready redirect — without bouncing the user through /login.html again. This is the mechanism behind "I'm already signed in here, take me through SSO to a second RP on this same host".

Request

  • assertion — the most recent sso_assertion from any successful sign-in flow. typ must be "sso"; access_token / id_token presented here are rejected (anti-confused-deputy).
  • return — an absolute or relative URL targeting /oauth2/v1/authorize (or /oidc.ashx?action=authorize) on this same host. Validated byte-for-byte against the registered client's redirect_uris before a code is minted.

Response

{
  "ok":            true,
  "redirect":      "/oauth2/v1/authorize?response_type=code&client_id=…&code=…",
  "user":          "alice",
  "role":          "admin",
  "sso_assertion": "<refreshed-jwt>"
}

The refreshed sso_assertion rolls forward inside the same absolute lifetime cap — the original auth_time claim is preserved, so the session does not become immortal just because the user keeps hopping between RPs. amr / acr are carried unchanged, so a resource server can still tell that the original factor was a passkey, magic-link, or EU Wallet rather than a password.

Errors

  • 400 invalid_requestassertion or return missing.
  • 400 invalid_returnreturn doesn't resolve to a valid /authorize for a registered client.
  • 401 invalid_assertion — signature failed, wrong issuer, wrong audience, missing sub, or typ is not "sso".
  • 401 assertion_expired — the absolute session lifetime (capped from original auth_time) has elapsed. The user must re-authenticate via /login or another start endpoint.

Example

curl -X POST -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "assertion=$SSO_ASSERTION" \
  --data-urlencode "return=/oauth2/v1/authorize?response_type=code&client_id=app&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcb&state=xyz&code_challenge=…&code_challenge_method=S256" \
  https://phone.codeb.io/oidc.ashx?action=sso-mint
The IdP holds no server-side session table for SSO — the assertion itself carries the state. That keeps multi-tenant deployments cleanly stateless, but means the absolute cap is enforced from auth_time: there is no "reset by activity" loophole.

GET /oidc.ashx?action=claim-info #

Tenant-onboarding claim flow — step 1. After an operator materialises a fresh tenant the first admin user ships with an all-zeros HA1 sentinel and a one-time signed claim token. claim.html calls this endpoint to find out whether the named user is still in claim-pending state, when the token expires, and a masked hint of the admin email that received the activation message.

Request

Query parameter: username=<u>. No body. No bearer.

Response — claim still available

{
  "ok":               true,
  "username":         "admin",
  "tenant":           "phone.codeb.io",
  "expiresUtc":       "2026-06-20T12:34:56Z",
  "expired":          false,
  "adminEmailMasked": "o***@example.com"
}

Response — claim not available

{
  "ok":      false,
  "code":    "not-claimable",
  "message": "This account is not awaiting a claim. The link may have already been used or the username is wrong."
}

The envelope is intentionally uniform regardless of whether the user record exists. Operators can still tell the cases apart from the server log; the public response cannot be used to enumerate accounts.

Errors

  • 400 invalid_requestusername missing or longer than 80 characters.

Example

curl 'https://phone.codeb.io/oidc.ashx?action=claim-info&username=admin'
The full claim token is never echoed by this endpoint. Operators receive it once at tenant-materialisation time via the admin-email channel and paste it into claim.html together with the new password.

POST /oidc.ashx?action=claim #

Tenant-onboarding claim flow — step 2. The operator pastes the one‑time claim token (delivered out‑of‑band to the admin email) plus the desired password into claim.html. The browser computes HA1 client-side and POSTs the triple here; the server consumes the token and atomically writes the new HA1 into the tenant credentials file.

Request

Form‑encoded body: username, claim_token, new_ha1 (32‑hex MD5 digest = md5(user:realm:password)).

Response

{
  "ok":   true,
  "user": "admin"
}

Errors

  • 400 invalid_request — missing field, or new_ha1 is not 32 lowercase hex, or the new password is the all‑zeros sentinel.
  • 400 invalid_or_used — uniform envelope when the user isn't in claim-pending state, the .claim file is missing, or the token hash doesn't match.
  • 400 claim_expired — the per-tenant claim window (default 7 days from materialisation) has passed.
  • 429 rate_limited — same per-IP login bucket as /oidc.ashx?action=login: 10 attempts / 60 seconds.
  • 500 claim_file_corrupt — the onboarding state file failed to parse. Ask the operator to re-onboard the tenant.

Server-side verification chain

  1. User must exist and still carry the claim-pending HA1 sentinel.
  2. App_Data/<tenant>/.claim JSON must parse and contain tokenSha256Hex + expiresUtc.
  3. Token expiry checked against UTC.
  4. sha256(claim_token) compared constant-time against tokenSha256Hex.
  5. New HA1 atomically written into the tenant credentials store via File.Replace with a rolling backup.
  6. The .claim file is deleted — the token is single-use.

Example

curl -X POST -H "Content-Type: application/x-www-form-urlencoded" \
  -d 'username=admin&claim_token=eyJh...&new_ha1=09e74a4ce3a0a37b7d9ee3e9b7e0c4aa' \
  https://phone.codeb.io/oidc.ashx?action=claim
Token shape: 22‑character base64url (128 bits of entropy). Brute-force inside the expiry window is infeasible; the per-IP rate-limit bucket throttles attempts further.
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.

Questions? Ask us · Index: All public APIs