Sign in with Bodek

Bodek Accounts is an OAuth 2.0 authorization server with OpenID Connect–style discovery and UserInfo. Add a “Sign in with Bodek” button to any site or app: your users authenticate with their existing Bodek account — including their two-factor method — and you receive a verified identity without ever handling their password.

Issuer   https://accounts.bodek.us
Grants   authorization_code (+ PKCE S256) · refresh_token
Tokens   Opaque bearer access tokens (1 hour), rotating refresh tokens (90 days)
Identity GET https://accounts.bodek.us/userinfo

Access tokens are opaque 64-character hex strings, not JWTs. Validate them with /userinfo or /introspect rather than decoding them.

No ID token. The token response does not include an id_token, and the discovery document's jwks_uri is null. Get the user's identity (sub, name, email…) from /userinfo with the access token. OIDC client libraries that insist on an ID token must be configured for plain OAuth 2.0 (“userinfo only”) mode. A nonce parameter is accepted on /authorize but, as no ID token is issued, it is not echoed anywhere.

Register an app

Sign in to the developer site and open the OAuth apps console (New app). Apps belong to your Bodek account.

FieldRules
NameRequired, up to 80 characters. Shown to users on the sign-in screen.
App typeConfidential — server-side apps; receives a client secret. Public — native/mobile/desktop apps and anything that can't keep a secret; no secret, PKCE is mandatory. The type is fixed once the app is created.
Redirect URIs1–10 URIs, one per line. Absolute URLs of at most 512 characters, https:// only — http:// is allowed for localhost, 127.0.0.1 and ::1. No #fragment. Matching at /authorize and /token is exact (scheme, host, port, path and query string).
ScopesAny of openid, profile, email, offline_access. openid is always added. The registered set is both the maximum an authorize request can obtain and the default when a request omits scope.
Homepage URLOptional http(s) URL; its host is shown as a link on the sign-in screen.
Logo URLOptional http(s) URL of an image shown on the sign-in screen.

On creation you receive a client_id (bk_ + 32 hex characters) and, for confidential apps, a client_secret (bks_ + 48 hex characters) that is shown once. Only a hash is stored; if you lose it, rotate it (see Managing your app).

Discovery & endpoints

The metadata document is served (with Access-Control-Allow-Origin: *) at both standard locations:

GET https://accounts.bodek.us/.well-known/openid-configuration
GET https://accounts.bodek.us/.well-known/oauth-authorization-server
{
  "issuer": "https://accounts.bodek.us",
  "authorization_endpoint": "https://accounts.bodek.us/authorize",
  "token_endpoint": "https://accounts.bodek.us/token",
  "userinfo_endpoint": "https://accounts.bodek.us/userinfo",
  "end_session_endpoint": "https://accounts.bodek.us/logout",
  "jwks_uri": null,
  "scopes_supported": ["openid", "profile", "email", "offline_access"],
  "response_types_supported": ["code"],
  "response_modes_supported": ["query"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "subject_types_supported": ["public"],
  "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic", "none"],
  "code_challenge_methods_supported": ["S256"],
  "claims_supported": ["sub", "name", "given_name", "family_name", "email", "email_verified", "picture", "phone_number"]
}
EndpointMethodURL
AuthorizationGEThttps://accounts.bodek.us/authorize
TokenPOSThttps://accounts.bodek.us/token
UserInfoGET / POSThttps://accounts.bodek.us/userinfo
IntrospectionPOSThttps://accounts.bodek.us/introspect
RevocationPOSThttps://accounts.bodek.us/revoke
End sessionGEThttps://accounts.bodek.us/logout

Introspection and revocation are not advertised in the discovery document; configure them explicitly if your library needs them.

Scopes

ScopeGrants
openidIdentifies the user (sub). On its own it also releases the profile claims (see UserInfo).
profilename, given_name, family_name, picture.
emailemail and email_verified.
offline_accessA refresh token is returned with the access token.

Separate scopes with spaces. The granted scope is the requested scope intersected with the scopes registered for your app; anything else is silently dropped (no error). If you omit scope, you get every scope registered for the app. The granted value is returned as scope in the token response — check it rather than assuming.

Token lifetimes

ArtifactFormatLifetimeNotes
Authorization code64 hex chars60 secondsSingle use. Replaying a used code revokes every token issued from it.
Access token64 hex chars3600 secondsReturned as expires_in. Bearer token for /userinfo.
Refresh token64 hex chars90 days (7,776,000 s)Rotates on every use; reuse revokes it (see Refresh tokens).

Authorize endpoint

GET https://accounts.bodek.us/authorize

Send the user's browser here (top-level navigation, not an iframe or XHR).

ParameterDescription
client_idrequiredYour app's client id.
redirect_urirequiredMust exactly equal one of the app's registered redirect URIs.
response_typeoptionalOnly code (the default).
scoperecommendedSpace-separated scopes, e.g. openid profile email. Defaults to the app's registered scopes.
staterecommendedOpaque value returned unchanged on the redirect. Use it for CSRF protection.
code_challengepublic apps: requiredBASE64URL(SHA256(code_verifier)). Optional (and recommended) for confidential apps.
code_challenge_methodwith a challengeMust be S256. plain is rejected, and so is an omitted method.
promptoptionallogin — make the user sign in (again or with another account) before continuing. select_account or consent — always show the account chooser.
nonceoptionalAccepted and stored, but not echoed (no ID token is issued).

What the user sees

If nobody is signed in to Bodek in that browser (or with prompt=login), the user signs in first — password plus any second factor — or creates an account. They then see a “Continue to your app” screen with your name, logo, homepage host and the requested permissions, and pick which of their signed-in Bodek accounts to use. Choosing an account approves the request; there is no separate “deny” button, so a user who backs out simply never returns to your callback.

Successful redirect

https://your-app.com/oauth/callback?code=4f1c…e9&state=RANDOM_STATE

Use code and state, and ignore any other parameters. Exchange the code within 60 seconds.

Error handling

If client_id or redirect_uri is missing, the client is unknown or disabled, or the redirect URI isn't registered, Bodek shows an error page and does not redirect (it can't trust the redirect URI). Other problems are sent to your redirect URI as ?error=…&error_description=…&state=…:

errorWhen
unsupported_response_typeresponse_type is not code.
invalid_requestA public app sent no code_challenge, or code_challenge_method isn't S256.

Authorization code flow (server-side)

For confidential apps with a backend: redirect the user, receive a code on your callback, exchange the code for tokens from your server, then call UserInfo.

1. Redirect the user to /authorize

Generate a random state, store it in the session, and send the user to:

https://accounts.bodek.us/authorize?
  response_type=code
  &client_id=YOUR_CLIENT_ID
  &redirect_uri=https://your-app.com/oauth/callback
  &scope=openid%20profile%20email
  &state=RANDOM_STATE

2. Handle the callback

Bodek redirects back with ?code=…&state=… (or ?error=…). Verify that state matches what you stored, then exchange the code.

3. Exchange the code at /token

curl -X POST https://accounts.bodek.us/token \
  -d grant_type=authorization_code \
  -d code=THE_CODE \
  -d redirect_uri=https://your-app.com/oauth/callback \
  -d client_id=YOUR_CLIENT_ID \
  -d client_secret=YOUR_CLIENT_SECRET

# or authenticate with HTTP Basic instead of body credentials
curl -X POST https://accounts.bodek.us/token -u YOUR_CLIENT_ID:YOUR_CLIENT_SECRET \
  -d grant_type=authorization_code -d code=THE_CODE \
  -d redirect_uri=https://your-app.com/oauth/callback

Minimal PHP example

<?php
session_start();
$issuer = 'https://accounts.bodek.us';
$clientId = 'YOUR_CLIENT_ID';
$clientSecret = 'YOUR_CLIENT_SECRET';
$redirectUri = 'https://your-app.com/oauth/callback';

// Step 1 — start.php
$_SESSION['oauth_state'] = bin2hex(random_bytes(16));
$params = http_build_query([
    'response_type' => 'code',
    'client_id'     => $clientId,
    'redirect_uri'  => $redirectUri,
    'scope'         => 'openid profile email',
    'state'         => $_SESSION['oauth_state'],
]);
header('Location: ' . $issuer . '/authorize?' . $params);
exit;

// Step 2 + 3 — callback.php
if (isset($_GET['error'])) { exit('Sign-in failed: ' . htmlspecialchars($_GET['error'])); }
if (!hash_equals($_SESSION['oauth_state'] ?? '', $_GET['state'] ?? '')) {
    http_response_code(400); exit('Bad state');
}
unset($_SESSION['oauth_state']);
$ch = curl_init($issuer . '/token');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POSTFIELDS => http_build_query([
        'grant_type'    => 'authorization_code',
        'code'          => $_GET['code'] ?? '',
        'redirect_uri'  => $redirectUri,
        'client_id'     => $clientId,
        'client_secret' => $clientSecret,
    ]),
]);
$tok = json_decode(curl_exec($ch), true);
if (empty($tok['access_token'])) { exit('Token error: ' . ($tok['error'] ?? 'unknown')); }

// Fetch the verified profile
$ch = curl_init($issuer . '/userinfo');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $tok['access_token']],
]);
$user = json_decode(curl_exec($ch), true);
// $user['sub'], $user['email'], $user['name'] …

Public apps (PKCE)

Apps that can't keep a secret — mobile, desktop and CLI apps — register as public and use PKCE (RFC 7636). The provider requires a code_challenge for public clients, and only the S256 method is supported.

  1. Create a random code_verifier: 43–128 characters from A–Z a–z 0–9 - . _ ~.
  2. Derive code_challenge = BASE64URL(SHA256(code_verifier)) (no padding).
  3. Send code_challenge and code_challenge_method=S256 on the authorize request.
  4. Send the original code_verifier with the token request, with client_id and no client secret.

Confidential apps may also send a challenge; once a code was issued with a challenge, the matching verifier is required to redeem it.

Browser apps: /token, /userinfo, /introspect and /revoke don't send CORS headers, so JavaScript on another origin can't read their responses. A single-page app should do the code exchange and UserInfo call from its own backend (which can then be a confidential client), or run where CORS doesn't apply (native, desktop, server).
// Generate the verifier + challenge (Web Crypto; also works in Node 18+)
const b64url = (bytes) => btoa(String.fromCharCode(...bytes))
  .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const verifier  = b64url(crypto.getRandomValues(new Uint8Array(32)));   // 43 chars
const digest    = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
const challenge = b64url(new Uint8Array(digest));

const authorizeUrl = 'https://accounts.bodek.us/authorize?' + new URLSearchParams({
  response_type: 'code',
  client_id: 'YOUR_CLIENT_ID',
  redirect_uri: 'http://127.0.0.1:8765/callback',
  scope: 'openid profile email offline_access',
  state: crypto.randomUUID(),
  code_challenge: challenge,
  code_challenge_method: 'S256',
});
// open authorizeUrl in the system browser, then receive ?code=… on the redirect URI
curl -X POST https://accounts.bodek.us/token \
  -d grant_type=authorization_code \
  -d code=THE_CODE \
  -d redirect_uri=http://127.0.0.1:8765/callback \
  -d client_id=YOUR_CLIENT_ID \
  -d code_verifier=THE_VERIFIER

Token endpoint

POST https://accounts.bodek.us/token

Body is application/x-www-form-urlencoded. Responses are JSON with Cache-Control: no-store.

Client authentication

MethodHow
client_secret_postclient_id and client_secret in the body.
client_secret_basicAuthorization: Basic base64(client_id:client_secret). Body values take precedence if both are sent.
nonePublic apps: client_id only (plus PKCE).

grant_type=authorization_code

ParameterDescription
coderequiredThe code from the callback.
redirect_urirequiredExactly the value used on /authorize.
client_idrequiredIn the body or via Basic auth.
client_secretconfidentialIn the body or via Basic auth.
code_verifierif PKCERequired when the code was issued with a code_challenge.

grant_type=refresh_token

ParameterDescription
refresh_tokenrequiredThe most recent refresh token.
scopeoptionalNarrow the scope (a subset of the original grant). Asking only for scopes that weren't granted returns invalid_scope.
client_id / client_secretrequired / confidentialSame client that received the token.

Response

HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store

{
  "access_token": "9b0e…c41a",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "openid profile email offline_access",
  "refresh_token": "71d2…0f3e"
}

From the authorization-code grant, refresh_token is present only when offline_access was granted. The refresh grant always returns a new refresh_token. There is no id_token.

UserInfo

GET https://accounts.bodek.us/userinfo

Returns the user's claims for a valid access token. Send the token as Authorization: Bearer … (recommended); an access_token query or form parameter (POST) is also accepted.

curl https://accounts.bodek.us/userinfo \
  -H "Authorization: Bearer ACCESS_TOKEN"
{
  "sub": "42",
  "name": "Ada Lovelace",
  "given_name": "Ada",
  "family_name": "Lovelace",
  "picture": "https://accounts.bodek.us/uploads/profiles/…jpg",
  "email": "ada@example.com",
  "email_verified": true
}
ClaimReleased withNotes
subalwaysStable account id as a string. Use this as your key — it never changes, even if the email does.
name, given_name, family_nameprofile or openid
pictureprofile or openidAbsolute avatar URL, or null when the user has none.
email, email_verifiedemailemail_verified is a boolean.

Errors: 401 with {"error":"invalid_token"} and a WWW-Authenticate: Bearer error="invalid_token" header when the token is missing, expired or revoked; 410 invalid_token if the account no longer exists.

Refresh tokens

Request the offline_access scope (and register it on your app) to receive a refresh token. Exchange it for a new access token when the old one expires:

curl -X POST https://accounts.bodek.us/token \
  -d grant_type=refresh_token \
  -d refresh_token=YOUR_REFRESH_TOKEN \
  -d client_id=YOUR_CLIENT_ID \
  -d client_secret=YOUR_CLIENT_SECRET   # omit for public clients
  • Rotation: each use returns a new refresh token (valid for a fresh 90 days) and invalidates the old refresh token and the access token issued with it.
  • Replay detection: presenting an already-used refresh token is treated as theft — it fails with invalid_grant, and (unless it happens within a few seconds of the first use) every access and refresh token your app holds for that user is revoked, so the user must sign in again. Always persist the latest token you receive, and serialize refreshes so two requests don't race with the same token.
  • A refresh token only works for the client it was issued to.

Introspection

POST https://accounts.bodek.us/introspect

Check whether an access token issued to your app is still active (RFC 7662 style). Send form fields; client credentials go in the body (HTTP Basic is not accepted here). Public apps send client_id only.

curl -X POST https://accounts.bodek.us/introspect \
  -d token=ACCESS_TOKEN \
  -d client_id=YOUR_CLIENT_ID \
  -d client_secret=YOUR_CLIENT_SECRET
{
  "active": true,
  "scope": "openid profile email",
  "client_id": "bk_…",
  "sub": "42",
  "exp": 1750003600,
  "iat": 1750000000,
  "token_type": "Bearer"
}

Anything else — an expired, revoked or unknown token, a refresh token, or a token belonging to another app — returns {"active": false}. Bad client credentials return 401 {"error":"invalid_client"}.

Revocation

POST https://accounts.bodek.us/revoke

Invalidate a token immediately — for example when the user signs out of your app (RFC 7009 style). Client credentials go in the body.

curl -X POST https://accounts.bodek.us/revoke \
  -d token=THE_TOKEN \
  -d token_type_hint=refresh_token \
  -d client_id=YOUR_CLIENT_ID \
  -d client_secret=YOUR_CLIENT_SECRET
  • token_type_hint is optional: access_token or refresh_token limits the lookup to that type; without it both are tried.
  • Revoking a refresh token also revokes the access token issued alongside it.
  • The response is always 200 with {} — including for unknown or already-revoked tokens — so you can't probe for valid tokens. Only tokens issued to your app are affected.
  • Errors: 400 {"error":"invalid_request"} without client_id; 401 {"error":"invalid_client"} for a bad client or secret.

Signing out

To end the user's session in your app, clear your own session and revoke their tokens. Bodek's end_session_endpoint (https://accounts.bodek.us/logout) signs the user out of Bodek itself; when opened from another site it shows a “Sign out?” confirmation page. It does not support post_logout_redirect_uri — its next parameter only redirects to Bodek sites — so the user stays on Bodek after signing out.

Managing your app

From the OAuth apps console you can at any time:

  • Edit the name, redirect URIs, scopes, homepage and logo. Changes apply to new authorization requests immediately.
  • Rotate the secret (confidential apps). A new secret is shown once and the old one stops working at once — deploy the new secret promptly. Existing tokens stay valid.
  • Disable the app: sign-ins show “Application disabled”, the token endpoint returns invalid_client, and all live access and refresh tokens are revoked. Re-enabling doesn't restore them.
  • Delete the app: revokes and removes all of its codes and tokens and the client itself. This can't be undone.

Two-factor & security

Authentication always happens on Bodek Accounts, never in your app. Before a code is issued, the user completes their entire Bodek login — password plus any enabled second factor (authenticator app, email code or passkey) — and chooses the account to continue with. Your app can't bypass or weaken that step.

  • Always send and verify a unique state value to defend against CSRF.
  • Use PKCE for any client that can't keep a secret (confidential clients can use it too).
  • Keep the client secret on your server. If it leaks, rotate it — the old one stops working immediately.
  • Redirect URIs are exact-match: register every callback you use, and prefer https://.
  • Treat sub as the user id; don't key accounts on email alone.
  • Disabling or deleting an app revokes its live tokens immediately.

Errors

Authorization errors are returned on your redirect URI as query parameters (error, error_description, state) — see Authorize endpoint. Token endpoint errors return JSON {"error": "…", "error_description": "…"}.

StatuserrorMeaning
400invalid_requestA required parameter is missing (e.g. client_id, code/redirect_uri, refresh_token), or PKCE is missing/invalid on authorize.
401invalid_clientUnknown client, wrong or missing secret, or the app is disabled.
429slow_downToo many failed client authentications from your IP (30 per 10 minutes); honour Retry-After.
400invalid_grantCode or refresh token is malformed, unknown, expired, already used, revoked, issued to another client, the redirect_uri doesn't match, or the PKCE verifier is missing or wrong. error_description says which.
400invalid_scopeA refresh asked only for scopes that weren't granted.
400unsupported_grant_typegrant_type isn't authorization_code or refresh_token.
redirectunsupported_response_typeOnly response_type=code is supported.
401 / 410invalid_tokenUserInfo: missing, invalid, expired or revoked access token (401), or deleted account (410).
405invalid_request / method_not_allowed/token, /introspect and /revoke only accept POST.