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