{"revision":"8e28655deab6e935","language":"en","entry":{"id":"sign-in","group":"guides","kind":"guide","title":"Sign in with Veil","summary":"Let people sign in to your app with their Veil card. The login is a signature made by their device key, and you can check it against the public log yourself.","search":"OpenID Connect · OIDC · OAuth 2.0 · PKCE · ID token · claims · veil_consent · /oidc/authorize","pageLinks":[{"anchor":"how-a-sign-in-works","label":"Flow"},{"anchor":"endpoints","label":"Endpoints"},{"anchor":"registering-an-app","label":"Register"},{"anchor":"the-authorization-request","label":"Request"},{"anchor":"try-the-flow","label":"Try it"},{"anchor":"scopes-and-claims","label":"Claims"},{"anchor":"tokens","label":"Tokens"},{"anchor":"what-to-verify","label":"Verify"},{"anchor":"honest-limits","label":"Limits"},{"anchor":"example-node","label":"Examples"},{"anchor":"reference","label":"Reference"}],"sections":[{"kind":"paragraph","text":"Veil is the public directory of cryptographic identities. Each person or organisation has one card that ties their keys to the accounts people know them by (Discord, GitHub, email, a domain, the Fediverse and more), with certified ties. Sign in with Veil is an OpenID Connect provider built on that directory. When someone signs in to your app, the identity you receive is their card, and the proof is a signature from a key that never left their browser."},{"kind":"paragraph","text":"If you build the app on the other side of the sign-in, this page covers registering it, the flow, the claims and the tokens, and what you can check yourself against the public log instead of taking the server's word. The issuer is `https://veil.rip`."},{"kind":"heading","text":"How a sign-in works"},{"kind":"steps","items":["Your app sends the browser to `https://veil.rip/oidc/authorize` with the usual code flow parameters and a PKCE challenge.","The consent page shows your app's name, its site, what it asks for, and the card this browser holds. In the browser a card is a device key: an Ed25519 key made there and kept in IndexedDB, which no page can read or export. No password is involved at any point.","When the person agrees, the browser signs a consent text with the device key. The text names your app, your redirect URI, the scopes, your nonce, a hash of your state, your PKCE challenge and the time.","Veil checks that signature against the card's active device keys and against the card's current version in the verified public log. It keeps the signed consent with the login and sends the browser back to you with `code`, `state` and `iss`.","Your server exchanges the code for an access token, a refresh token and an ID token signed by Veil."]},{"kind":"paragraph","text":"If the browser holds no card, the page offers the usual ways in: make a card, or add this browser to an existing card with the backup file or another device. Then it comes back to your request, which waits 30 minutes for that."},{"kind":"paragraph","text":"The person approves your app once. The next sign-in with the same scopes needs no click, but the browser still signs the consent, so every login is a fresh signature. For a native app that comes back on the loopback interface (`http://127.0.0.1`, `[::1]` or `localhost`) the page asks every time, because any program on the same computer could be listening at that address (RFC 8252, 8.6)."},{"kind":"paragraph","text":"On their account page the person sees the apps they signed in to, each login with its signed consent, and can revoke any of those apps there."},{"kind":"heading","text":"Endpoints"},{"kind":"paragraph","text":"The discovery document, `GET https://veil.rip/.well-known/openid-configuration`, lists every endpoint. Each name below opens its page in the reference."},{"kind":"table","columns":["Endpoint","Address"],"rows":[[{"text":"Discovery","ref":"oidc-discovery"},"`https://veil.rip/.well-known/openid-configuration`"],[{"text":"Authorization","ref":"oidc-authorize"},"`https://veil.rip/oidc/authorize`"],[{"text":"Token","ref":"oidc-token"},"`https://veil.rip/oidc/token`"],[{"text":"UserInfo","ref":"oidc-userinfo"},"`https://veil.rip/oidc/userinfo` (GET or POST)"],[{"text":"JWKS","ref":"oidc-jwks"},"`https://veil.rip/oidc/jwks`"],[{"text":"Registration (RFC 7591)","ref":"oidc-register"},"`https://veil.rip/oidc/register`"],[{"text":"Revocation (RFC 7009)","ref":"oidc-revoke"},"`https://veil.rip/oidc/revoke`"],[{"text":"Introspection (RFC 7662)","ref":"oidc-introspect"},"`https://veil.rip/oidc/introspect`"],[{"text":"End session","ref":"oidc-logout"},"`https://veil.rip/oidc/logout`"]]},{"kind":"paragraph","text":"The provider supports:"},{"kind":"list","items":["Response type `code` only, with response mode `query`.","PKCE with `S256` for every app, public and confidential.","The `iss` parameter in every authorization response (RFC 9207).","ID tokens signed with `EdDSA` (Ed25519) or `ES256`.","Client authentication with `client_secret_basic` or `private_key_jwt`, and `none` for public apps."]},{"kind":"paragraph","text":"Request objects, `request_uri` and the `claims` parameter are not supported."},{"kind":"paragraph","text":"The discovery document and the JWKS answer cross-origin requests, and nothing else under `/oidc/` does. A browser app therefore needs a small backend of its own that holds the tokens and talks to the token endpoint (the backend for frontend pattern). Veil API keys play no part under `/oidc/` either: the `Authorization` header there carries your app's secret or a person's access token."},{"kind":"heading","text":"Registering an app"},{"kind":"heading","text":"From /developers","level":3},{"kind":"paragraph","text":"On `https://veil.rip/developers` a card registers its apps, signed by the device key in that browser like everything else on the card. One of an organisation's owners registers the organisation's apps. A card can have 20 apps."},{"kind":"table","columns":["Field","Rules"],"rows":[["Name","1 to 60 characters. The consent page shows it."],["Site","An https URL of at most 300 characters. The consent page shows its host."],["Redirect URIs","1 to 10. Each one is https, or http on the loopback interface (`127.0.0.1`, `[::1]`, `localhost`) for a native app, with no fragment and no user information, in at most 512 printable ASCII characters."],["Logout redirect URIs","0 to 10, with the same rules."],["Type","`confidential` (a server, with `client_secret_basic` or `private_key_jwt`) or `public` (a native app, with PKCE alone)."],["JWKS","For `private_key_jwt`, 1 to 8 public keys: Ed25519, P-256, or RSA of 2048 to 4096 bits with a small odd exponent (65537, as usual). You give the keys themselves, never a URL."],["ID token algorithm","`EdDSA` (the default) or `ES256`."],["Refresh tokens","On by default."],["Logo","A PNG or GIF of at most 256 KiB and 512 × 512 pixels, checked by the same rules as a card's picture."]]},{"kind":"paragraph","text":"When the app is made, Veil shows its client id (`veil_app_` and 32 hex digits) and, for a server, its secret (`veil_secret_…`). The secret is shown only that once, and Veil keeps only its SHA-256 hash. A new secret replaces the old one at once, and deleting the app ends every token it holds."},{"kind":"paragraph","text":"An app is verified when its card proves, with the directory's domain proof, the domain of the app's site and of every https redirect URI. The consent page then says \"Verified: the owner of example.org\". Any other app gets a quiet \"Unverified app\" line, and the page always names the card that registered it."},{"kind":"heading","text":"Dynamic registration","level":3},{"kind":"paragraph","text":"`POST https://veil.rip/oidc/register` is open to anyone (RFC 7591), within these limits:"},{"kind":"list","items":["At most 10 registrations an hour per IP address.","The app belongs to no card, so it is never verified, and it may not call itself Veil.","It expires 30 days after registration (`client_secret_expires_at` and `veil_expires_at` in the response).","`token_endpoint_auth_method` is `client_secret_basic` when omitted, as RFC 7591 says, so send `none` for a public app.","For `private_key_jwt` the `jwks` go inline, `jwks_uri` is refused, and `logo_uri` is neither fetched nor kept."]},{"kind":"code","language":"shell","code":"curl -fsS https://veil.rip/oidc/register -H 'Content-Type: application/json' -d '{\n  \"client_name\": \"Notes CLI\",\n  \"redirect_uris\": [\"http://127.0.0.1/callback\"],\n  \"token_endpoint_auth_method\": \"none\",\n  \"grant_types\": [\"authorization_code\", \"refresh_token\"],\n  \"application_type\": \"native\"\n}'"},{"kind":"heading","text":"The authorization request"},{"kind":"code","language":"text","code":"https://veil.rip/oidc/authorize?response_type=code\n  &client_id=veil_app_62bab7367bb583e2157490ae2cb65d4d\n  &redirect_uri=https%3A%2F%2Fnotes.example.org%2Fcallback\n  &scope=openid%20profile%20veil\n  &state=…&nonce=…\n  &code_challenge=…&code_challenge_method=S256"},{"kind":"list","items":["`redirect_uri` must match a registered one exactly, except that a loopback URI matches on any port (RFC 8252). An unknown app or an unregistered redirect URI is refused on the consent page, and the browser is never sent there.","`scope` must include `openid`. An unknown scope is `invalid_scope`.","`code_challenge` (43 characters) and `code_challenge_method=S256` are required for every app. Without them the answer is `invalid_request`.","`state` takes up to 1024 characters and `nonce` up to 512. Send both.","`prompt` takes `none`, `login`, `consent` and `select_account`. With `none` the answer is `login_required` when the browser holds no card, and `consent_required` when the card has not approved your app for these scopes or your redirect URI is a loopback one.","`max_age=0` counts as `prompt=login`, so the page waits for the click. Any other `max_age` is met already, because every sign-in is a signature made at that moment (`auth_time`).","`ui_locales=it` shows the page in Italian. English is the default."]},{"kind":"paragraph","text":"The answer reaches your redirect URI as `code`, `state` and `iss=https://veil.rip`, or as `error`, `error_description`, `state` and `iss`. Check `state` and `iss` before you use the code."},{"kind":"try-flow"},{"kind":"heading","text":"Scopes and claims"},{"kind":"table","columns":["Scope","Claims","What the person is told"],"rows":[["`openid`","`sub`, and in the ID token `auth_time`, `amr`, `sid`","Your card's number"],["`profile`","`name`, `picture`, `preferred_username`, `profile`","Your name and picture"],["`veil`","`veil_version`, `veil_log`, `veil_good_standing`, `veil_standing_reasons`, and in the ID token `veil_consent`","Your card's standing: whether it is in good standing, the version this sign-in is checked against, and the signature you make now"],["`veil:accounts`","`veil_accounts`","Your verified accounts, listed by platform and handle"],["`veil:orgs`","`veil_orgs`","Your organisations"],["`veil:keys`","`veil_keys`","Your public keys"]]},{"kind":"table","columns":["Claim","Meaning"],"rows":[["`sub`","The card's number as a string (`\"7\"`). It stays the same for the card's whole life, and an alias never changes it."],["`name`","The name on the card. Names are not unique in Veil, so show it with the number and identify people by `sub`. While the staff hide a name, it is `Card <n>`."],["`picture`","The address of the card's picture. Absent (or `null` in UserInfo) when the card has none."],["`preferred_username`","The card's alias when it has one, else `u<n>`. An alias can change or be released."],["`profile`","`https://veil.rip/u/<n>`, the card's page."],["`veil_version`","The card's version the login was checked against. In UserInfo, its current version."],["`veil_log`","The log entry of that version and the checkpoint the server verified it in: `{origin, index, size, root}`, where `root` is the checkpoint's root hash in base64."],["`veil_good_standing`","`true` when none of the reasons below applies."],["`veil_standing_reasons`","Why not: `suspended` (by the Veil staff), `closing` (the card asked to be closed and has 24 hours to cancel), `takeover` (during the 7-day wait, when another card asks for one of its accounts or it asks for one), `name_hidden`, `key_revoked` (the device key that signed this login has been revoked since), `key_banned`."],["`veil_consent`","ID token only. The consent exactly as the device key signed it, `{text, signature, key, alg}`. See \"What to verify\"."],["`veil_accounts`","The verified accounts the card holds now: `[{platform, id, handle, url, verified_at}]`. `id` is the platform's stable id (Discord and GitHub user ids, an email address, a domain, a Fediverse address), and `url` is `null` where the platform has no profile page. A pending account (during a takeover) is not listed."],["`veil_orgs`","The organisations the person belongs to, accepted by both sides: `[{n, name, role}]`."],["`veil_keys`","`{devices: [{key, alg}], external: [{type, fp}]}`: the active device keys, and the fingerprints of the public keys from elsewhere (SSH, OpenPGP, minisign…) that the card certifies."],["`amr`","`[\"veil-key\"]`: the login was a device key's signature."],["`auth_time`","When that signature was checked."],["`sid`","The login's id, `l<number>`."]]},{"kind":"paragraph","text":"An ID token from a test server, with its addresses written as they are on veil.rip:"},{"kind":"code","language":"json","code":"{\"iss\": \"https://veil.rip\", \"sub\": \"1\", \"aud\": \"veil_app_62bab7367bb583e2157490ae2cb65d4d\", \"azp\": \"veil_app_62bab7367bb583e2157490ae2cb65d4d\",\n \"name\": \"Elena Ferraro\", \"preferred_username\": \"u1\", \"profile\": \"https://veil.rip/u/1\",\n \"veil_version\": 1, \"veil_log\": {\"origin\": \"log.veil.rip/staging-1\", \"index\": 0, \"size\": 10, \"root\": \"JkW148VFq8g8MXHJArRQ/38q3ix5BrhSjAmHSAW8+P4=\"},\n \"veil_good_standing\": true, \"veil_standing_reasons\": [],\n \"iat\": 1793525884, \"exp\": 1793526484, \"auth_time\": 1793525884, \"nonce\": \"ozFe35gs4K2fjVX7jcR81jTMf5X8CNR6X12cZSMuIi0\",\n \"amr\": [\"veil-key\"], \"sid\": \"l9\",\n \"veil_consent\": {\"text\": \"veil-rubrica:oidc-consent:v1\\n{\\\"card\\\":1,\\\"challenge\\\":\\\"OpmVnrcB…\\\",…,\\\"v\\\":1}\",\n                  \"signature\": \"ac8e0fb6…026a06\", \"key\": \"120a568ad456a6dfdc16d48d1ab91af826180545a13123a49134968446f57195\", \"alg\": \"ed25519\"}}"},{"kind":"heading","text":"Tokens"},{"kind":"table","columns":["Token","Form","Lifetime"],"rows":[["Code","Opaque, single use","10 minutes. Used twice, it ends every token of its login."],["Access token","Opaque, `veil_at_…`, a Bearer token for UserInfo","10 minutes"],["Refresh token","Opaque, `veil_rt_…`, bound to your app","30 days, a new one at every use, and at most 90 days after the login"],["ID token","JWT signed by Veil, with `kid`","10 minutes"]]},{"kind":"paragraph","text":"Veil stores every code and token as a SHA-256 hash. The token endpoint takes `application/x-www-form-urlencoded`, each parameter once."},{"kind":"list","items":["`grant_type=authorization_code` needs `code`, `redirect_uri` and `code_verifier`.","`grant_type=refresh_token` needs `refresh_token`, and `scope` may narrow the scopes as long as it keeps `openid`. The answer carries a new refresh token, and the old one stops working. A refresh token used a second time ends the whole login, because someone copied it.","A refresh fails with `invalid_grant` once the card is closed or suspended, once the device key that signed the login is revoked from the card, and once the person revokes your app from their account page.","`client_secret_basic` is HTTP Basic with the client id and secret. For `private_key_jwt`, the client assertion has `iss` and `sub` set to your client id, `aud` set to `https://veil.rip` or the token endpoint's address, `exp` at most 5 minutes ahead, and a `jti` of 8 to 256 characters that works once. Your key's type fixes its algorithm. A public app sends `client_id`."]},{"kind":"paragraph","text":"The ID token is signed with `EdDSA` unless your app registered `ES256`. Many libraries expect `RS256` when nothing is configured, so set `id_token_signed_response_alg` in your client's configuration to the algorithm you registered."},{"kind":"paragraph","text":"The JWKS holds both keys. Each key has a single `alg`, and its `kid` is its RFC 7638 thumbprint. Keys rotate with a 30-day overlap, so a replaced key stays in the JWKS for 30 days. Cache the JWKS for minutes, and fetch it again when a token names a `kid` you do not have."},{"kind":"heading","text":"UserInfo","level":3},{"kind":"paragraph","text":"`GET` or `POST /oidc/userinfo` with `Authorization: Bearer <access token>` returns the claims of the token's scopes from the card's state now: its current version, the log's last verified checkpoint, its standing, and its accounts, organisations and keys. A revoked key or a suspension shows at once."},{"kind":"paragraph","text":"For a suspended card the answer is `401` with `WWW-Authenticate: Bearer error=\"veil_suspended\"` and `{\"error\": \"veil_suspended\"}`. A closed card's tokens answer `invalid_token`, because closing a card revokes every token it gave."},{"kind":"heading","text":"Revocation and introspection","level":3},{"kind":"paragraph","text":"`POST /oidc/revoke` (RFC 7009) takes `token`, authenticated as at the token endpoint. Revoking a refresh token ends every token of its login. A token that is not your app's, or not a token at all, still gets `200`."},{"kind":"paragraph","text":"`POST /oidc/introspect` (RFC 7662) lets a confidential app ask about its own tokens. The answer is `{active, scope, client_id, sub, aud, iss, iat, exp, token_type, auth_time, veil_good_standing}`, or `{\"active\": false}`."},{"kind":"heading","text":"Logout","level":3},{"kind":"paragraph","text":"Veil keeps no session to end, because a login is a signature. `GET /oidc/logout?id_token_hint=<an ID token>&post_logout_redirect_uri=…&state=…` revokes the tokens of that login (its `sid`) and sends the browser back to the logout URI if your app registered that exact address. Otherwise a page tells the person they are signed out of your app."},{"kind":"heading","text":"Errors","level":3,"anchor":"token-errors"},{"kind":"paragraph","text":"The OAuth endpoints answer errors in their protocol's shape, `{\"error\": \"invalid_grant\", \"error_description\": \"…\"}`, with `Cache-Control: no-store`. Rate limits keep that shape on those endpoints too: `429` with `Retry-After` and `{\"error\": \"rate_limited\", \"error_description\": \"…\"}`."},{"kind":"paragraph","text":"The limits are 1200 calls a minute per IP on the back channel (token, revocation, introspection, UserInfo), 600 a minute per app once authenticated, 60 authorization requests a minute per IP, and 10 registrations an hour per IP. The consent page's JSON and the management APIs use the directory's envelope, `{\"error\": {\"code\", \"message\"}}`."},{"kind":"heading","text":"What to verify"},{"kind":"heading","text":"Every app","level":3},{"kind":"paragraph","text":"These are the standard OpenID Connect checks. A maintained library does them for you, though many need step 3 switched on."},{"kind":"checks","items":[{"text":"`state` and `iss` in the authorization response are yours and `https://veil.rip`.","why":"`state` ties the answer to the browser that started the sign-in, so another site cannot slip its own code into your callback. `iss` says which provider answered, so an app that talks to several providers cannot be handed one provider's code as another's (RFC 9207)."},{"text":"The code goes back with your PKCE verifier.","why":"Only your server knows the verifier behind the challenge it sent, so a code that leaks on the way (a log, a referrer, another app on the same device) is useless to whoever picks it up."},{"text":"The ID token's signature verifies with the JWKS key its `kid` names, with that key's algorithm. Never let the header choose the algorithm, and never accept `none` or an HMAC algorithm. Many libraries skip this check for an ID token that comes straight from the token endpoint, because OpenID Connect lets TLS stand in for it there. openid-client skips it unless you call `enableNonRepudiationChecks`. Turn the check on.","why":"With the check on, the token you keep proves what Veil said about who signed in. Taking the algorithm from the key, and never from the header, stops a forged token that names `none` or uses the public key as an HMAC secret."},{"text":"`iss` is `https://veil.rip`, `aud` (and `azp`) is your client id, `exp` is in the future, `iat` is not, and `nonce` is yours.","why":"A valid signature only says that Veil made the token. These claims say that it was made for your app, for this sign-in, and recently, so a token issued to another app or replayed from an earlier login fails here."},{"text":"Identify the person by `sub`. Show `name` with the card's number or address.","why":"`sub` is the card's number and stays the same for the card's whole life. Names are not unique in Veil, and an alias can change or be released, so neither can stand for the person."},{"text":"If your app cares about standing, read `veil_good_standing`, and ask UserInfo again before an important action.","why":"The ID token describes the card at the moment of the login. UserInfo reflects the card now, so a suspension, a takeover or a revoked key shows there at once."}]},{"kind":"heading","text":"Veil's edge: the login checked against the log","level":3,"anchor":"log-check"},{"kind":"paragraph","text":"The checks above trust the server to say who signed in. With the `veil` scope you can check the person's own signature and the public log instead."},{"kind":"checks","items":[{"text":"Parse `veil_consent.text`. The first line is `veil-rubrica:oidc-consent:v1`, and the rest is canonical JSON with `v`, `iss`, `card`, `key`, `client`, `redirect`, `scopes`, `nonce`, `state`, `challenge`, `request` and `time`.","why":"The first line says what kind of text the key signed, so a signature the key made for anything else in Veil cannot pass for a login consent."},{"text":"Verify `veil_consent.signature` (hex Ed25519) over the exact bytes of `text` with `veil_consent.key`.","why":"This signature comes from the person's device key. The server has no copy of that key and cannot make it."},{"text":"Check that `challenge` is the S256 of your own PKCE verifier, `nonce` is your nonce, `state` is the SHA-256 (hex) of your state, `client` is your client id, `iss` is the issuer, `key` is `veil_consent.key` and `card` is `sub`. `veil_consent.alg` is `ed25519`.","why":"Your server made the verifier for this login alone, so a consent that names its challenge was signed for this login and no other."},{"text":"Ask the server where to look. `GET /v1/keys/{key}` gives the card and the version that added the key (`addedIn`). `GET /v1/identities/{sub}` gives the card's `token`, which is how the log names the card, and its current `version`. `veil_version` must lie between the two.","why":"Nothing in this step is proof yet. It tells you which entries of the log to read in the next one."},{"text":"Check it in the log, for every version from `addedIn` to the current one. `GET /v1/identities/{sub}/versions/{v}/proof` gives the log entry, its index, the inclusion proof and a checkpoint. The entry's `label` must name the card (`identity` is the token), the version (`n`) and the version before it (`prev` is the previous proof's `labelHash`). The leaf hash is `SHA-256(0x00 || entry)`, and the inclusion proof (RFC 6962) must lead to the checkpoint's root. The first of those versions must add the key as a device (`label.auth` holds `{\"op\": \"addKey\", \"key\": <key>, \"role\": \"device\"}`), no later one may revoke it (`revokeKey`), and the proof of `veil_version` must be at `veil_log.index`. A card rarely has many versions after a key came in, and you can keep the ones you checked for next time.","why":"Until here only the server said it. Now the public log says that the key was added to this card as a device, that no later version revoked it, and that the login's version is the entry Veil named. A revoked device, another card's entry passed off as this one's, or a moved version fails here."},{"text":"Check each checkpoint against a copy of the log's policy that you keep with your app (`https://log.veil.rip/<log>/policy`, the log of `veil_log.origin`): the log's own signature, cosignatures from enough of the policy's witnesses to meet its quorum, and a size of at least `veil_log.size`.","why":"The witnesses are independent operators, so a server that showed you a version of the card nobody else sees would be caught."}]},{"kind":"paragraph","text":"Veil's conformance test runs the Node and Python examples below against a server that lies. They refuse a consent signed by a device the card revoked, another card's log entry passed off as this one's, a revoked key the server calls active, and a login version moved."},{"kind":"heading","text":"Honest limits","level":3},{"kind":"list","style":"limits","items":["The log knows a card by its token, not by its number. Going from `sub` to the token is the server's word.","Step 5 does not check the signature on each entry. The log takes an entry only when a key with authority on the card signed it, and anyone can check those signatures again by replaying the card's authority chain (`/v1/identities/{n}/authority`), as the card pages do in the browser with `veil-v2.js`. That is the fully independent check.","A server that hides the card's newest versions, and says the card is still at an older one, is not caught by these steps. That check belongs to the index of the log's format 2.","The accounts, organisations and keys in the claims are the server's reading of the card. Each of them is also on the card's signed state, which a card page checks in the reader's browser.","An app that checks only the OpenID Connect part trusts the server for the login itself. A server under someone else's control could issue a token for any card to such an app. An app that checks `veil_consent` against its own PKCE verifier cannot be fooled that way without the person's device key. The key never leaves the browser, but the pages that use it come from the same server. A server under someone else's control could send the browser a page that signs a consent for an attacker's login the next time the person opens Veil, and the chat's hidden key frame is such a page too. Any key that a web page holds has this limit.","The person sees every login on their account page, with its signed text and signature. The page shows them and does not check them. Signed login receipts in the public log, so that a person could detect a token issued without them even to an app that does not check, are an open point for later.","A server can still refuse to answer, or serve an older checkpoint. Compare the witnesses' cosignature times with the clock if freshness matters to you."]},{"kind":"heading","text":"Example: Node","anchor":"example-node"},{"kind":"paragraph","text":"The flow uses `openid-client` (pinned to 6.8.8 in Veil's own conformance test), and the Veil checks use `node:crypto`. The PKCE verifier, the state and the nonce stay on your server, keyed by a cookie. It needs Node 20 or later."},{"kind":"code","language":"javascript","code":"// Sign in with Veil in a Node server: openid-client for the OpenID Connect flow, node:crypto for the Veil checks.\n// npm install openid-client@6.8.8      node veil-signin.mjs\nimport * as oidc from \"openid-client\";\nimport { createHash, createPublicKey, verify, randomBytes } from \"node:crypto\";\nimport { createServer } from \"node:http\";\n\nconst ISSUER = new URL(process.env.VEIL_ISSUER ?? \"https://veil.rip\");\nconst CLIENT_ID = process.env.VEIL_CLIENT_ID;          // from /developers\nconst CLIENT_SECRET = process.env.VEIL_CLIENT_SECRET;  // shown once, keep it in your secret store\nconst REDIRECT = process.env.VEIL_REDIRECT ?? \"http://localhost:3000/callback\";\n// A copy of the log's policy, kept with your app: the log's key and its witnesses (https://log.veil.rip/<log>/policy).\nconst POLICY = process.env.VEIL_POLICY_TEXT;\n\nconst sha256 = (b) => createHash(\"sha256\").update(b).digest();\nconst ed25519 = (hex) => createPublicKey({ key: { kty: \"OKP\", crv: \"Ed25519\", x: Buffer.from(hex, \"hex\").toString(\"base64url\") }, format: \"jwk\" });\n\n// RFC 6962: the root an inclusion proof leads to, or null.\nfunction rootFrom(leaf, index, size, path) {\n  if (index >= size) return null;\n  let fn = BigInt(index), sn = BigInt(size) - 1n, r = leaf;\n  for (const p of path) {\n    if (sn === 0n) return null;\n    if ((fn & 1n) === 1n || fn === sn) {\n      r = sha256(Buffer.concat([Buffer.from([1]), p, r]));\n      if ((fn & 1n) === 0n) while ((fn & 1n) === 0n && fn !== 0n) { fn >>= 1n; sn >>= 1n; }\n    } else r = sha256(Buffer.concat([Buffer.from([1]), r, p]));\n    fn >>= 1n; sn >>= 1n;\n  }\n  return sn === 0n ? r : null;\n}\n\n// A C2SP note key \"name+hash+base64(type || key)\": type 0x01 for the log, 0x04 for a witness.\nfunction noteKey(vkey) {\n  const i = vkey.indexOf(\"+\"), j = vkey.indexOf(\"+\", i + 1);\n  const name = vkey.slice(0, i), raw = Buffer.from(vkey.slice(j + 1), \"base64\");\n  const id = sha256(Buffer.concat([Buffer.from(name + \"\\n\"), raw])).subarray(0, 4);\n  if (id.toString(\"hex\") !== vkey.slice(i + 1, j)) throw new Error(`bad vkey ${name}`);\n  return { name, id, key: ed25519(raw.subarray(1).toString(\"hex\")) };\n}\n\n// The checkpoint's log signature, and how many witnesses of the policy's quorum group cosigned it.\nfunction checkCheckpoint(note, policyText) {\n  const text = note.slice(0, note.indexOf(\"\\n\\n\") + 1);\n  const sigs = note.slice(text.length + 1).split(\"\\n\").filter((l) => l.startsWith(\"— \")).map((l) => l.slice(2).split(\" \"));\n  const lines = policyText.split(\"\\n\").map((l) => l.trim().split(/\\s+/)).filter((f) => f[0] && !f[0].startsWith(\"#\"));\n  const log = noteKey(lines.find((f) => f[0] === \"log\")[1]);\n  const witnesses = new Map(lines.filter((f) => f[0] === \"witness\").map((f) => [f[1], noteKey(f[2])]));\n  const group = lines.find((f) => f[0] === \"group\" && f[1] === lines.find((q) => q[0] === \"quorum\")[1]);\n  const members = group.slice(3);\n  const needed = group[2] === \"all\" ? members.length : group[2] === \"any\" ? 1 : Number(group[2]);\n  let logSigned = false; const cosigned = new Set();\n  for (const [name, b64] of sigs) {\n    const blob = Buffer.from(b64, \"base64\");\n    if (name === log.name && blob.subarray(0, 4).equals(log.id)) logSigned ||= verify(null, Buffer.from(text), log.key, blob.subarray(4));\n    for (const [id, w] of witnesses) {\n      if (name !== w.name || blob.length !== 76 || !blob.subarray(0, 4).equals(w.id)) continue;\n      const msg = `cosignature/v1\\ntime ${blob.readBigUInt64BE(4)}\\n${text}`;\n      if (verify(null, Buffer.from(msg), w.key, blob.subarray(12))) cosigned.add(id);\n    }\n  }\n  const [origin, size, root] = text.split(\"\\n\");\n  return { logSigned, quorum: members.filter((m) => cosigned.has(m)).length >= needed, origin, size: Number(size), root };\n}\n\n/**\n * Veil's own checks on the claims of a verified ID token (scope \"veil\"): the person's device key signed this very login\n * (your PKCE challenge, your nonce, your client id), and the public log, not the server, says the key is on the card: the\n * version that added it, every version after it up to the card's current one (none revokes it), and the version the login\n * was checked against, each one this card's entry, in one chain, under a checkpoint the log signed and a quorum of witnesses\n * cosigned.\n */\nexport async function verifyVeil(claims, { verifier, nonce, state, clientId, issuer, policyText }) {\n  const c = claims.veil_consent;\n  if (!c || c.alg !== \"ed25519\" || !c.text.startsWith(\"veil-rubrica:oidc-consent:v1\\n\")) throw new Error(\"no signed consent\");\n  if (!verify(null, Buffer.from(c.text, \"utf8\"), ed25519(c.key), Buffer.from(c.signature, \"hex\"))) throw new Error(\"consent signature\");\n  const s = JSON.parse(c.text.slice(c.text.indexOf(\"\\n\") + 1));\n  if (s.challenge !== sha256(verifier).toString(\"base64url\")) throw new Error(\"not your PKCE challenge\");\n  if (s.client !== clientId || s.nonce !== nonce || s.iss !== issuer || s.key !== c.key || String(s.card) !== claims.sub) throw new Error(\"not your login\");\n  if (s.state !== sha256(state).toString(\"hex\")) throw new Error(\"not your state\");\n  const get = async (path) => { const r = await fetch(issuer + path); if (!r.ok) throw new Error(`${path}: ${r.status}`); return r.json(); };\n  // The server says where to look: the version that added the key, the card's id in the log (its token), its current version.\n  const key = await get(`/v1/keys/${c.key}`);\n  const card = await get(`/v1/identities/${claims.sub}`);\n  if (String(key.identity?.n) !== claims.sub || key.role !== \"device\") throw new Error(\"key not on the card\");\n  if (!(key.addedIn <= claims.veil_version && claims.veil_version <= card.version)) throw new Error(\"the login's version is not one where the key was on the card\");\n  // The log says the rest.\n  let prev = null;\n  for (let v = key.addedIn; v <= card.version; v++) {\n    const proof = await get(`/v1/identities/${claims.sub}/versions/${v}/proof`);\n    const label = JSON.parse(JSON.parse(proof.entry).label);\n    if (label.identity !== card.token || label.n !== v || (prev !== null && label.prev !== prev)) throw new Error(`the log's entry is not version ${v} of this card`);\n    const leaf = sha256(Buffer.concat([Buffer.from([0]), Buffer.from(proof.entry, \"utf8\")]));\n    const root = rootFrom(leaf, proof.logIndex, proof.checkpoint.size, proof.inclusion.map((h) => Buffer.from(h, \"hex\")));\n    if (!root || root.toString(\"base64\") !== proof.checkpoint.root) throw new Error(\"inclusion proof\");\n    const cp = checkCheckpoint(proof.checkpoint.note, policyText);\n    if (!cp.logSigned || !cp.quorum || cp.origin !== claims.veil_log.origin || cp.root !== proof.checkpoint.root || cp.size !== proof.checkpoint.size) throw new Error(\"checkpoint\");\n    if (cp.size < claims.veil_log.size) throw new Error(\"an older checkpoint than the login's\");\n    const auth = label.auth ?? [];\n    if (v === key.addedIn && !auth.some((op) => op.op === \"addKey\" && op.key === c.key && op.role === \"device\")) throw new Error(\"the log does not add this key here\");\n    if (v > key.addedIn && auth.some((op) => op.op === \"revokeKey\" && op.key === c.key)) throw new Error(\"the log revokes this key\");\n    if (v === claims.veil_version && proof.logIndex !== claims.veil_log.index) throw new Error(\"another log entry\");\n    prev = proof.labelHash;\n  }\n  return { card: Number(claims.sub), version: claims.veil_version, goodStanding: claims.veil_good_standing };\n}\n\n// The flow, with the PKCE verifier, state and nonce kept server-side per browser (a cookie names the session).\nif (import.meta.url === `file://${process.argv[1]}`) {\n  // enableNonRepudiationChecks: without it openid-client does not check the ID token's signature (TLS stands in for it).\n  const config = await oidc.discovery(ISSUER, CLIENT_ID, { id_token_signed_response_alg: \"EdDSA\" }, oidc.ClientSecretBasic(CLIENT_SECRET),\n    { execute: [oidc.enableNonRepudiationChecks] });\n  const sessions = new Map();\n  createServer(async (req, res) => {\n    const url = new URL(req.url, REDIRECT);\n    const sid = /sid=([A-Za-z0-9_-]+)/.exec(req.headers.cookie ?? \"\")?.[1];\n    if (url.pathname === \"/login\") {\n      const s = { verifier: oidc.randomPKCECodeVerifier(), state: oidc.randomState(), nonce: oidc.randomNonce() };\n      const id = randomBytes(16).toString(\"base64url\");\n      sessions.set(id, s);\n      const to = oidc.buildAuthorizationUrl(config, { redirect_uri: REDIRECT, scope: \"openid profile veil veil:accounts\", state: s.state, nonce: s.nonce,\n        code_challenge: await oidc.calculatePKCECodeChallenge(s.verifier), code_challenge_method: \"S256\" });\n      res.writeHead(302, { Location: to.href, \"Set-Cookie\": `sid=${id}; HttpOnly; Secure; SameSite=Lax; Path=/` }).end();\n    } else if (url.pathname === \"/callback\" && sessions.has(sid)) {\n      const s = sessions.get(sid); sessions.delete(sid);\n      try {\n        // state, iss (RFC 9207), PKCE, the ID token's signature (with enableNonRepudiationChecks), iss, aud, exp and nonce.\n        const tokens = await oidc.authorizationCodeGrant(config, url, { pkceCodeVerifier: s.verifier, expectedState: s.state, expectedNonce: s.nonce });\n        const claims = tokens.claims();\n        const veil = await verifyVeil(claims, { ...s, clientId: CLIENT_ID, issuer: config.serverMetadata().issuer, policyText: POLICY });\n        res.writeHead(200, { \"Content-Type\": \"text/plain\" }).end(`Signed in as card ${veil.card} (${claims.name}), checked against the log\\n`);\n      } catch (e) { res.writeHead(400, { \"Content-Type\": \"text/plain\" }).end(`Sign-in refused: ${e.message}\\n`); }\n    } else res.writeHead(404).end();\n  }).listen(3000);\n}"},{"kind":"heading","text":"Example: Python","anchor":"example-python"},{"kind":"paragraph","text":"Python's standard library has no Ed25519 or ECDSA, so this example uses `cryptography` (pyca), a widely packaged library that has both. Everything else comes from the standard library. Run the flow in your web framework: `start_login` gives the redirect and the values to keep in the session, `finish_login` handles the callback, and `verify_veil` adds the checks against the log."},{"kind":"code","language":"python","code":"\"\"\"Sign in with Veil from Python: the standard library for HTTP, JSON and hashes, and pyca/cryptography for the two\nsignature schemes (Ed25519, ECDSA P-256), which the standard library does not have.   pip install cryptography\"\"\"\nimport base64, hashlib, json, secrets, time, urllib.parse, urllib.request\nfrom cryptography.exceptions import InvalidSignature\nfrom cryptography.hazmat.primitives import hashes\nfrom cryptography.hazmat.primitives.asymmetric import ec\nfrom cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey\nfrom cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature\n\nISSUER = \"https://veil.rip\"\n\n\ndef b64url(data: str) -> bytes:\n    return base64.urlsafe_b64decode(data + \"=\" * (-len(data) % 4))\n\n\ndef get_json(url: str, data: bytes | None = None, headers: dict | None = None):\n    req = urllib.request.Request(url, data=data, headers={\"Accept\": \"application/json\", **(headers or {})})\n    with urllib.request.urlopen(req, timeout=15) as r:\n        return json.load(r)\n\n\ndef ed25519_ok(key: bytes, sig: bytes, msg: bytes) -> bool:\n    try:\n        Ed25519PublicKey.from_public_bytes(key).verify(sig, msg)\n        return True\n    except (InvalidSignature, ValueError):\n        return False\n\n\n# ---------- the flow ----------\n\ndef start_login(discovery: dict, client_id: str, redirect_uri: str) -> tuple[str, dict]:\n    \"\"\"The authorization URL, and what to keep server-side for the callback (never in the URL or a readable cookie).\"\"\"\n    session = {\"verifier\": secrets.token_urlsafe(48), \"state\": secrets.token_urlsafe(24), \"nonce\": secrets.token_urlsafe(24)}\n    challenge = base64.urlsafe_b64encode(hashlib.sha256(session[\"verifier\"].encode()).digest()).rstrip(b\"=\").decode()\n    query = urllib.parse.urlencode({\"response_type\": \"code\", \"client_id\": client_id, \"redirect_uri\": redirect_uri,\n                                    \"scope\": \"openid profile veil veil:accounts\", \"state\": session[\"state\"], \"nonce\": session[\"nonce\"],\n                                    \"code_challenge\": challenge, \"code_challenge_method\": \"S256\"})\n    return f\"{discovery['authorization_endpoint']}?{query}\", session\n\n\ndef finish_login(discovery: dict, client_id: str, secret: str, redirect_uri: str, callback_query: str, session: dict) -> dict:\n    \"\"\"The callback: state and iss first (RFC 9207), then the code for tokens, then the ID token's checks.\"\"\"\n    q = dict(urllib.parse.parse_qsl(callback_query))\n    if q.get(\"state\") != session[\"state\"] or q.get(\"iss\") != discovery[\"issuer\"]:\n        raise ValueError(\"state or iss is not this sign-in's\")\n    if \"error\" in q:\n        raise ValueError(f\"the sign-in did not happen: {q['error']}\")\n    form = urllib.parse.urlencode({\"grant_type\": \"authorization_code\", \"code\": q[\"code\"], \"redirect_uri\": redirect_uri,\n                                   \"code_verifier\": session[\"verifier\"]}).encode()\n    basic = base64.b64encode(f\"{urllib.parse.quote(client_id, safe='')}:{urllib.parse.quote(secret, safe='')}\".encode()).decode()\n    tokens = get_json(discovery[\"token_endpoint\"], form, {\"Authorization\": f\"Basic {basic}\", \"Content-Type\": \"application/x-www-form-urlencoded\"})\n    return verify_id_token(discovery, tokens[\"id_token\"], client_id, session[\"nonce\"])\n\n\ndef verify_id_token(discovery: dict, token: str, client_id: str, nonce: str) -> dict:\n    \"\"\"The JWS checked with the key its kid names, with that key's one algorithm (never the header's choice), then the claims.\"\"\"\n    head_b64, body_b64, sig_b64 = token.split(\".\")\n    header, claims, sig = json.loads(b64url(head_b64)), json.loads(b64url(body_b64)), b64url(sig_b64)\n    jwk = next(k for k in get_json(discovery[\"jwks_uri\"])[\"keys\"] if k[\"kid\"] == header[\"kid\"])\n    signed = f\"{head_b64}.{body_b64}\".encode()\n    if header[\"alg\"] != jwk[\"alg\"]:\n        raise ValueError(\"the token's alg is not its key's\")\n    if jwk[\"alg\"] == \"EdDSA\":\n        ok = ed25519_ok(b64url(jwk[\"x\"]), sig, signed)\n    elif jwk[\"alg\"] == \"ES256\":\n        key = ec.EllipticCurvePublicNumbers(int.from_bytes(b64url(jwk[\"x\"])), int.from_bytes(b64url(jwk[\"y\"])), ec.SECP256R1()).public_key()\n        try:\n            key.verify(encode_dss_signature(int.from_bytes(sig[:32]), int.from_bytes(sig[32:])), signed, ec.ECDSA(hashes.SHA256()))\n            ok = True\n        except InvalidSignature:\n            ok = False\n    else:\n        ok = False\n    now = time.time()\n    if not ok or claims[\"iss\"] != discovery[\"issuer\"] or claims[\"aud\"] != client_id or claims.get(\"azp\", client_id) != client_id:\n        raise ValueError(\"ID token signature, issuer or audience\")\n    if claims[\"exp\"] < now - 30 or claims[\"iat\"] > now + 30 or claims.get(\"nonce\") != nonce:\n        raise ValueError(\"ID token expired, from the future, or not this sign-in's nonce\")\n    return claims\n\n\n# ---------- Veil's own checks ----------\n\ndef root_from(leaf: bytes, index: int, size: int, path: list[bytes]) -> bytes | None:\n    \"\"\"RFC 6962: the root an inclusion proof leads to.\"\"\"\n    if index >= size:\n        return None\n    fn, sn, r = index, size - 1, leaf\n    for p in path:\n        if sn == 0:\n            return None\n        if fn & 1 or fn == sn:\n            r = hashlib.sha256(b\"\\x01\" + p + r).digest()\n            while not fn & 1 and fn != 0:\n                fn, sn = fn >> 1, sn >> 1\n        else:\n            r = hashlib.sha256(b\"\\x01\" + r + p).digest()\n        fn, sn = fn >> 1, sn >> 1\n    return r if sn == 0 else None\n\n\ndef note_key(vkey: str) -> tuple[str, bytes, bytes]:\n    name, digest, b64 = vkey.split(\"+\", 2)\n    raw = base64.b64decode(b64)\n    key_id = hashlib.sha256(name.encode() + b\"\\n\" + raw).digest()[:4]\n    if key_id.hex() != digest:\n        raise ValueError(f\"bad vkey {name}\")\n    return name, key_id, raw[1:]\n\n\ndef check_checkpoint(note: str, policy_text: str) -> dict:\n    \"\"\"The log's signature on the checkpoint, and whether the policy's quorum of witnesses cosigned it.\"\"\"\n    text = note[: note.index(\"\\n\\n\") + 1]\n    sigs = [line[2:].split(\" \") for line in note[len(text) + 1:].split(\"\\n\") if line.startswith(\"— \")]\n    lines = [line.split() for line in policy_text.splitlines() if line.strip() and not line.startswith(\"#\")]\n    log_name, log_id, log_key = note_key(next(f for f in lines if f[0] == \"log\")[1])\n    witnesses = {f[1]: note_key(f[2]) for f in lines if f[0] == \"witness\"}\n    quorum_group = next(f for f in lines if f[0] == \"quorum\")[1]\n    group = next(f for f in lines if f[0] == \"group\" and f[1] == quorum_group)\n    members, rule = group[3:], group[2]\n    needed = len(members) if rule == \"all\" else 1 if rule == \"any\" else int(rule)\n    log_signed, cosigned = False, set()\n    for name, sig_b64 in sigs:\n        blob = base64.b64decode(sig_b64)\n        if name == log_name and blob[:4] == log_id and ed25519_ok(log_key, blob[4:], text.encode()):\n            log_signed = True\n        for wid, (wname, wkey_id, wkey) in witnesses.items():\n            if name == wname and len(blob) == 76 and blob[:4] == wkey_id:\n                msg = f\"cosignature/v1\\ntime {int.from_bytes(blob[4:12])}\\n{text}\".encode()\n                if ed25519_ok(wkey, blob[12:], msg):\n                    cosigned.add(wid)\n    origin, size, root = text.split(\"\\n\")[:3]\n    return {\"log_signed\": log_signed, \"quorum\": len(cosigned & set(members)) >= needed, \"origin\": origin, \"size\": int(size), \"root\": root}\n\n\ndef verify_veil(claims: dict, session: dict, client_id: str, issuer: str, policy_text: str) -> dict:\n    \"\"\"The person's device key signed this very login, and the public log, not the server, says the key is on the card: the\n    version that added it, every version after it up to the card's current one (none revokes it), the login's version.\"\"\"\n    c = claims[\"veil_consent\"]\n    if c.get(\"alg\") != \"ed25519\" or not c[\"text\"].startswith(\"veil-rubrica:oidc-consent:v1\\n\"):\n        raise ValueError(\"not a consent\")\n    if not ed25519_ok(bytes.fromhex(c[\"key\"]), bytes.fromhex(c[\"signature\"]), c[\"text\"].encode()):\n        raise ValueError(\"consent signature\")\n    s = json.loads(c[\"text\"].split(\"\\n\", 1)[1])\n    challenge = base64.urlsafe_b64encode(hashlib.sha256(session[\"verifier\"].encode()).digest()).rstrip(b\"=\").decode()\n    if s[\"challenge\"] != challenge or s[\"nonce\"] != session[\"nonce\"] or s[\"client\"] != client_id or s[\"iss\"] != issuer \\\n            or s[\"key\"] != c[\"key\"] or str(s[\"card\"]) != claims[\"sub\"]:\n        raise ValueError(\"this consent is not for this login\")\n    if s[\"state\"] != hashlib.sha256(session[\"state\"].encode()).hexdigest():\n        raise ValueError(\"not this login's state\")\n    # The server says where to look: the version that added the key, the card's id in the log (its token), its current version.\n    key = get_json(f\"{issuer}/v1/keys/{c['key']}\")\n    card = get_json(f\"{issuer}/v1/identities/{claims['sub']}\")\n    if str(key[\"identity\"][\"n\"]) != claims[\"sub\"] or key[\"role\"] != \"device\":\n        raise ValueError(\"the key is not on the card\")\n    if not key[\"addedIn\"] <= claims[\"veil_version\"] <= card[\"version\"]:\n        raise ValueError(\"the login's version is not one where the key was on the card\")\n    # The log says the rest.\n    prev = None\n    for v in range(key[\"addedIn\"], card[\"version\"] + 1):\n        proof = get_json(f\"{issuer}/v1/identities/{claims['sub']}/versions/{v}/proof\")\n        label = json.loads(json.loads(proof[\"entry\"])[\"label\"])\n        if label[\"identity\"] != card[\"token\"] or label[\"n\"] != v or (prev is not None and label[\"prev\"] != prev):\n            raise ValueError(f\"the log's entry is not version {v} of this card\")\n        leaf = hashlib.sha256(b\"\\x00\" + proof[\"entry\"].encode()).digest()\n        root = root_from(leaf, proof[\"logIndex\"], proof[\"checkpoint\"][\"size\"], [bytes.fromhex(h) for h in proof[\"inclusion\"]])\n        if root is None or base64.b64encode(root).decode() != proof[\"checkpoint\"][\"root\"]:\n            raise ValueError(\"inclusion proof\")\n        cp = check_checkpoint(proof[\"checkpoint\"][\"note\"], policy_text)\n        if not (cp[\"log_signed\"] and cp[\"quorum\"] and cp[\"origin\"] == claims[\"veil_log\"][\"origin\"] and cp[\"root\"] == proof[\"checkpoint\"][\"root\"]):\n            raise ValueError(\"checkpoint signatures\")\n        if cp[\"size\"] < claims[\"veil_log\"][\"size\"]:\n            raise ValueError(\"an older checkpoint than the login's\")\n        auth = label.get(\"auth\", [])\n        if v == key[\"addedIn\"] and not any(op.get(\"op\") == \"addKey\" and op.get(\"key\") == c[\"key\"] and op.get(\"role\") == \"device\" for op in auth):\n            raise ValueError(\"the log does not add this key here\")\n        if v > key[\"addedIn\"] and any(op.get(\"op\") == \"revokeKey\" and op.get(\"key\") == c[\"key\"] for op in auth):\n            raise ValueError(\"the log revokes this key\")\n        if v == claims[\"veil_version\"] and proof[\"logIndex\"] != claims[\"veil_log\"][\"index\"]:\n            raise ValueError(\"another log entry\")\n        prev = proof[\"labelHash\"]\n    return {\"card\": int(claims[\"sub\"]), \"version\": claims[\"veil_version\"], \"good_standing\": claims[\"veil_good_standing\"]}"},{"kind":"paragraph","text":"Veil's conformance test (`server/browser-test/oidc-conformance.mjs`, `OidcConformanceTest`) takes both examples from `docs/oidc.md` in the repository, which holds the same code as this page, and runs them against a Veil server with a real log and witnesses, on honest sign-ins and on tampered ones."},{"kind":"heading","text":"Reference"},{"kind":"paragraph","text":"Each endpoint has its own page with its parameters, its answers and its errors."},{"kind":"links","ids":["oidc-discovery","oidc-jwks","oidc-authorize","oidc-token","oidc-userinfo","oidc-revoke","oidc-introspect","oidc-register","oidc-logout","oidc-clients","oidc-client-create","oidc-apps"]}],"related":["api-keys","signed-requests","proofs"],"errors":[],"documentationUrl":"https://veil.rip/docs/sign-in"}}