Sign in with Voznest
Access
Sign in with Voznest: OAuth 2.0 with PKCE, and OpenID Connect.
Access lets a person sign in to your site with Voznest, share their hubs and roles, or add your agent to a hub they manage — with their explicit consent. It follows OAuth 2.0 authorization code with PKCE, and Voznest is also a full OpenID Connect provider (see OpenID Connect).
| Value | |
|---|---|
| Authorize | https://voznest.com/oauth/authorize |
| Token | POST https://api.voznest.com/api/v1/oauth/token |
| Revoke | POST https://api.voznest.com/api/v1/oauth/revoke |
| Introspect | POST https://api.voznest.com/api/v1/oauth/introspect |
| UserInfo (OIDC) | GET https://api.voznest.com/api/v1/oauth/userinfo |
| Client ID | <project id> (Production) or <project id>.development / .staging — each environment has its own redirect URLs and secret |
| PKCE | S256 only. Required when the environment says so (Access). |
| Lifetimes | Code 5 min · access token 1 h · refresh token 30 days · ID token 1 h |
Scopes
| Scope | Grants |
|---|---|
openid | OpenID Connect: an id_token in the token response, and /oauth/userinfo. Always allowed. |
identify | Username, display name, avatar and banner — GET /oauth/@me |
profile | Bio, pronouns and badges (added to /oauth/@me) |
email | Email address and whether it's verified (added to /oauth/@me) |
hubs | The hubs the person belongs to — GET /oauth/@me/hubs |
hubs.members.read | Their roles and permissions in a hub — GET /oauth/@me/hubs/:id/member (e.g. only let a hub's admins in) |
connections | The linked accounts they show on their profile (GitHub, Steam…) — GET /oauth/@me/connections |
agent.install | Install the project's agent into a hub the person manages (they pick it on the consent screen) |
Only scopes enabled for the environment in Access can be requested (openid always can). Consent is all or nothing: the person approves every scope you asked for, or cancels — ask only for what you need.
Redirect URLs
They must match exactly what you registered for that environment. In Development, a registered loopback URL (http://localhost/callback, http://127.0.0.1/…, http://[::1]/…) also matches on any port (RFC 8252): register it once and use :3000, :5173, :8080… If a redirect URL doesn't match, the error says what arrived, which environment was searched and, when it's registered elsewhere, which client_id you meant.
// 1. Send the person to the consent screen (PKCE, S256)const verifier = base64url(crypto.randomBytes(32));const challenge = base64url(crypto.createHash("sha256").update(verifier).digest());const url = new URL("https://voznest.com/oauth/authorize");url.search = new URLSearchParams({ client_id: "<project id>", // "<project id>.development" for Development redirect_uri: "https://example.com/callback", // registered in Access, exact match response_type: "code", scope: "identify email", state: csrfToken, code_challenge: challenge, code_challenge_method: "S256",}).toString();// 2. On your callback: exchange the code (valid 5 minutes, single use)const res = await fetch("https://api.voznest.com/api/v1/oauth/token", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ grant_type: "authorization_code", code, redirect_uri: "https://example.com/callback", client_id: "<project id>", client_secret: process.env.VOZNEST_CLIENT_SECRET, // or code_verifier for public clients code_verifier: verifier, }),});const { access_token, refresh_token, expires_in, scope } = await res.json();// 3. Call the API as that personconst me = await fetch("https://api.voznest.com/api/v1/oauth/@me", { headers: { Authorization: `Bearer ${access_token}` } }).then((r) => r.json());The token response
// POST /oauth/token → a plain object (RFC 6749 §5.1), NOT the { success, data } envelope{ "access_token": "qat_…", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "qrt_…", "scope": "openid identify email", // the scopes granted (always all of the ones requested) "id_token": "eyJhbGciOiJFUzI1NiIs…" // only with the openid scope}GET /oauth/@me
// GET /oauth/@me (envelope: { "success": true, "data": … }){ "id": "65f0a1b2c3d4e5f6a7b8c9d0", "username": "ronard", "displayName": "Ronard", "avatarUrl": "https://api.voznest.com/files/avatars/….webp", // absolute, or null "bannerUrl": null, "hue": 250, // fallback avatar colour (0-359) when avatarUrl is null "bannerHue": 290, "profileUrl": "https://voznest.com/app/profile/65f0a1b2c3d4e5f6a7b8c9d0", "createdAt": "2024-03-12T18:22:05.000Z", // profile scope "bio": "…", "pronouns": "they/them", "badges": ["early_supporter"], // email scope "email": "[email protected]", "emailVerified": true}// Images: add ?s=64|128|256|512 for a resized WebP (cached for a year; the URL changes when the image does).Refreshing
await fetch("https://api.voznest.com/api/v1/oauth/token", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ grant_type: "refresh_token", refresh_token, client_id: "<project id>", client_secret }),});// → a NEW access_token and a NEW refresh_token. The old refresh token stops working;// presenting it again revokes every token of that authorization.Revoking and introspecting
# Sign the person out of your app (either token of the pair revokes both)curl -X POST https://api.voznest.com/api/v1/oauth/revoke -d token=$REFRESH_TOKEN -d token_type_hint=refresh_token -d client_id=$CLIENT_ID -d client_secret=$CLIENT_SECRET# → 200 {} — always, even if the token was already revoked or never existed (RFC 7009 §2.2)# Is this token live? (confidential clients, their own tokens only)curl -u "$CLIENT_ID:$CLIENT_SECRET" -d "token=$TOKEN" https://api.voznest.com/api/v1/oauth/introspect# → { "active": true, "scope": "identify email", "client_id": "…", "sub": "…", "username": "…",# "token_type": "Bearer", "exp": 1792653600, "iat": 1792650000, "iss": "https://voznest.com" }# → { "active": false } for an expired, revoked or unknown tokenConsent and prompt
Once someone authorized your app, asking again for the same (or fewer) scopes skips the consent screen. Add prompt=consent to always show it, or prompt=none to never show it: the browser comes straight back with a code, or with error=consent_required / error=login_required.
When a person revokes access
People see every app they signed in to in Settings → Authorized Apps and can revoke it. Subscribe to oauth.authorization.revoked and user.deleted (Event Stream) to erase what you keep about them; their tokens stop working immediately.
Errors
Errors from /oauth/token, /oauth/revoke and /oauth/introspect use the standard { "error": "invalid_grant", "error_description": "…" } shape (not the envelope). These endpoints accept JSON or application/x-www-form-urlencoded, and the client can authenticate with body fields or HTTP Basic.
| error | When |
|---|---|
invalid_request | A required parameter is missing or malformed |
invalid_client | Unknown client_id, wrong client_secret, or a public client without PKCE (401) |
invalid_grant | The code or refresh token is invalid, expired, already used, issued to another client, or the PKCE verifier/redirect_uri doesn't match |
unauthorized_client | The client isn't allowed to use this grant |
unsupported_grant_type | grant_type isn't authorization_code or refresh_token |
invalid_scope | A scope isn't enabled for the environment (returned to the redirect URL at authorize time) |