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.
https://server.tabbio.com/.well-known/oauth-authorization-serverIt 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
- Your server generates a
code_verifier, hashes it into acode_challenge, and stores the verifier with astatevalue against the visitor's session. - You redirect the browser to the authorize endpoint.
- The person signs in to Tabbio if they are not already, and sees the consent screen.
- Tabbio redirects back to your
redirect_uriwithcodeand yourstate. - Your server exchanges the code plus the verifier for tokens.
- 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.
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
https://app.tabbio.com/oauth/authorize| Field | Type | Description |
|---|---|---|
| response_typerequired | string | Always code. |
| client_idrequired | string | From your app on the developer platform. |
| redirect_urirequired | string | Must match a registered URI exactly. |
| scoperequired | string | Space separated. Ask for the least you need. One of |
| state | string | Opaque value echoed back verbatim. Use it to tie the callback to the session that started it. |
| code_challengerequired | string | Base64url SHA-256 of the verifier. |
| code_challenge_methodrequired | string | Always S256. One of |
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=S256https://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:*andhttp://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://callbackfor a mobile app.
Everything else must be https.
Step 3: consent
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:
https://partner.example/callback?code=tbo_ac_yLh0dJ8Q1s5B&state=s_7c2a91Compare 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:
https://partner.example/callback?error=access_denied&state=s_7c2a91Two 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
https://server.tabbio.com/oauth/tokenSend 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.
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"const response = await fetch("https://server.tabbio.com/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: process.env.TABBIO_REDIRECT_URI,
code_verifier: verifier,
client_id: process.env.TABBIO_CLIENT_ID,
client_secret: process.env.TABBIO_CLIENT_SECRET,
}),
});
const tokens = await response.json();import os
import requests
response = requests.post(
"https://server.tabbio.com/oauth/token",
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": os.environ["TABBIO_REDIRECT_URI"],
"code_verifier": verifier,
"client_id": os.environ["TABBIO_CLIENT_ID"],
"client_secret": os.environ["TABBIO_CLIENT_SECRET"],
},
)
tokens = response.json(){
"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.
{
"error": "invalid_grant",
"error_description": "The authorization code has already been used."
}| Field | Type | Description |
|---|---|---|
| invalid_request | 400 | A required field is missing. |
| invalid_client | 401 | Unknown client id, or the secret does not match. Carries WWW-Authenticate: Basic. |
| invalid_grant | 400 | Expired, already used, wrong client, redirect URI mismatch, or PKCE verification failed. |
| invalid_scope | 400 | On refresh, you asked for a scope wider than the grant. |
| unauthorized_client | 400 | The app may not use this grant type. |
| unsupported_grant_type | 400 | 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.
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.