Sign in with Tabbio

Authorization flow

Every parameter of the authorization code flow with PKCE, what comes back, and what each failure means.

Sign in with Tabbio is OAuth 2.0 authorization code with PKCE (RFC 6749 and RFC 7636). If you already have an OAuth client, point it at the discovery document and skip to Scopes and consent.

GEThttps://server.tabbio.com/.well-known/oauth-authorization-server

It names the authorize, token, revocation and introspection endpoints, the scopes, and the fact that only S256 is accepted. The machine readable API description lives next to it at https://server.tabbio.com/v1/openapi.json.

The sequence

  1. Your server generates a code_verifier, hashes it into a code_challenge, and stores the verifier with a state value against the visitor's session.
  2. You redirect the browser to the authorize endpoint.
  3. The person signs in to Tabbio if they are not already, and sees the consent screen.
  4. Tabbio redirects back to your redirect_uri with code and your state.
  5. Your server exchanges the code plus the verifier for tokens.
  6. You call /v1/* with the access token.

Steps 1, 5 and 6 happen on your server. Steps 2 to 4 happen in the person's browser.

Step 1: PKCE

The verifier is a random string. The challenge is its SHA-256, base64url encoded. Only S256 is accepted; plain is rejected even for public clients.

pkce.js
import crypto from "node:crypto";

const verifier = crypto.randomBytes(32).toString("base64url");
const challenge = crypto
  .createHash("sha256")
  .update(verifier)
  .digest()
  .toString("base64url");

PKCE is required for confidential clients too. It costs you six lines and it closes the code interception attack even when a client secret leaks.

Step 2: the authorization request

GEThttps://app.tabbio.com/oauth/authorize
FieldTypeDescription
response_typerequiredstring

Always code.

client_idrequiredstring

From your app on the developer platform.

redirect_urirequiredstring

Must match a registered URI exactly.

scoperequiredstring

Space separated. Ask for the least you need.

One of profile, email, cv:read, cv:contact

statestring

Opaque value echoed back verbatim. Use it to tie the callback to the session that started it.

code_challengerequiredstring

Base64url SHA-256 of the verifier.

code_challenge_methodrequiredstring

Always S256.

One of S256

Authorization request
https://app.tabbio.com/oauth/authorize
  ?response_type=code
  &client_id=tbo_ci_9dK2mQ7xVr4tN0pLzY6b
  &redirect_uri=https%3A%2F%2Fpartner.example%2Fcallback
  &scope=profile%20email%20cv%3Aread
  &state=s_7c2a91
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256

https://server.tabbio.com/oauth/authorize accepts the same query and redirects to the page above, so a client configured from the discovery document works unchanged.

Redirect URI matching

Matching is an exact string comparison against the list registered on your app. A trailing slash, a different scheme, or an extra query parameter is a different URI and is rejected.

Two deliberate exceptions:

  • Loopback ports. http://127.0.0.1:* and http://localhost:* match regardless of port, because a native app cannot reserve one in advance (RFC 8252 section 7.3).
  • Custom schemes. Public clients may register myapp://callback for a mobile app.

Everything else must be https.

The person sees your app name, your logo if you set one, and exactly the scopes you asked for. They choose which CV to share and whether to include the profile photo and private contact details. Scopes and consent covers what they see and what they can turn off.

Step 4: the callback

On approval:

Text
https://partner.example/callback?code=tbo_ac_yLh0dJ8Q1s5B&state=s_7c2a91

Compare state against what you stored before doing anything else. A mismatch means this callback did not come from a flow you started; discard it.

On refusal:

Text
https://partner.example/callback?error=access_denied&state=s_7c2a91

Two classes of failure, two behaviours

A malformed request that Tabbio can attribute to your app (an unknown scope, a missing code_challenge) redirects back to you with error and state. A request where the client id is unknown or the redirect URI does not match is not redirected: it renders an error page on Tabbio. Redirecting an unverified URI would turn the consent screen into an open redirect.

Step 5: the exchange

POSThttps://server.tabbio.com/oauth/token

Send application/x-www-form-urlencoded. Authenticate with HTTP Basic (client_id:client_secret) or with the fields in the body. Public clients send client_id alone.

Terminal
curl -X POST https://server.tabbio.com/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "code=tbo_ac_yLh0dJ8Q1s5B" \
  -d "redirect_uri=https://partner.example/callback" \
  -d "code_verifier=$CODE_VERIFIER" \
  -d "client_id=$TABBIO_CLIENT_ID" \
  -d "client_secret=$TABBIO_CLIENT_SECRET"
200 OK
{
  "access_token": "tbo_at_9QpX2f",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "tbo_rt_4Kd81a",
  "scope": "profile email cv:read"
}

This response is flat, not enveloped: RFC 6749 specifies the body and Tabbio follows it. It carries Cache-Control: no-store.

Codes are single use

An authorization code is valid for 10 minutes and can be exchanged once. Presenting a consumed code revokes every token that was minted from it, which is the RFC 6749 response to a code that has clearly been intercepted. If you see a user logged out right after signing in, look for a double exchange: a retried request, or a callback handler running twice.

Errors from the token endpoint

The body is flat here too.

400 Bad Request
{
  "error": "invalid_grant",
  "error_description": "The authorization code has already been used."
}
FieldTypeDescription
invalid_request400

A required field is missing.

invalid_client401

Unknown client id, or the secret does not match. Carries WWW-Authenticate: Basic.

invalid_grant400

Expired, already used, wrong client, redirect URI mismatch, or PKCE verification failed.

invalid_scope400

On refresh, you asked for a scope wider than the grant.

unauthorized_client400

The app may not use this grant type.

unsupported_grant_type400

Only authorization_code and refresh_token exist.

invalid_grant covers several distinct causes on purpose: telling an attacker which part of a failed exchange was wrong is a hint they can use.

Step 6: calling the API

Send the access token as a bearer token against https://server.tabbio.com.

Terminal
curl https://server.tabbio.com/v1/me \
  -H "Authorization: Bearer $TABBIO_ACCESS_TOKEN"

Access tokens last one hour. Tokens covers refreshing them, and what a token that stops working is telling you.

/v1/me* responses carry Cache-Control: no-store, so nothing in front of your server caches a person's CV by accident.