OAuth2 Developer Guide — building third-party apps for Vetrix
Vetrix is an OAuth2 / RFC 6749 authorization-code-flow authorization server. This guide walks through registering an OAuth2 app, completing the consent flow, and calling the Vetrix API on behalf of a user.
If you only need to authenticate users — without API calls — see the Vetrix-as-OAuth2-client guide (Part A) for "Sign in with Google / GitHub / GitLab / Microsoft" setup. This document covers Part B: third parties calling Vetrix.
TL;DR
- Register an app at
/settings/applications.- Send the user to
/api/v1/oauth2/authorizewith PKCE.- Exchange the code at
/api/v1/oauth2/token.- Call the API with
Authorization: Bearer vetrix_oat_….- Rotate via
/api/v1/oauth2/tokengrant_type=refresh_token.- Revoke via
/api/v1/oauth2/revoke.
1. Endpoints at a glance
| Method | Path | Notes |
|---|---|---|
| GET | /api/v1/oauth2/authorize |
Consent screen / auto-approve decision |
| POST | /api/v1/oauth2/authorize |
Submit consent decision (Authorize / Cancel) |
| POST | /api/v1/oauth2/token |
grant_type=authorization_code or refresh_token |
| POST | /api/v1/oauth2/revoke |
RFC 7009 — public + confidential |
| POST | /api/v1/oauth2/introspect |
RFC 7662 — confidential clients only |
| GET | /api/v1/user/oauth2/apps |
Caller's registered apps |
| POST | /api/v1/user/oauth2/apps |
Register; raw client_secret returned once |
| POST | /api/v1/user/oauth2/apps/{id}/rotate-secret |
Rotate; new secret returned once |
| GET | /api/v1/user/oauth2/authorizations |
Caller's live grants |
| DELETE | /api/v1/user/oauth2/authorizations/{grant_id} |
Revoke grant (cascades to tokens) |
| GET | /api/v1/admin/oauth2/apps |
Admin: instance-wide app list |
| POST | /api/v1/admin/oauth2/apps/{id}/suspend / unsuspend |
Admin kill switch (immediate) |
| GET | /api/v1/admin/oauth2/audit |
Admin: oauth2.* audit trail |
2. Picking a client type
| Type | When | Secret? | PKCE? |
|---|---|---|---|
public |
Mobile / desktop / single-page / CLI with loopback redirect | ❌ No | ✅ Required |
confidential |
Server-side web apps that can keep a long-lived secret safe | ✅ Yes | ✅ Required (defence in depth) |
Public clients must not present a client_secret and must provide a
PKCE code_challenge. Confidential clients must present the secret over
HTTP Basic or POST body, and may additionally provide PKCE.
admin:* scopes (admin:repo, read:audit) cannot be issued to a public
client — register your tooling as confidential if you need them.
3. Scope catalog
| Scope | Allows |
|---|---|
read:user |
Read the authenticated user's profile |
read:repo |
Browse repos / branches / files / commits |
write:repo |
Push commits, create branches |
admin:repo |
Repo settings, collaborators, branch deletion (admin-only) |
read:issue |
Read issues, comments, labels |
write:issue |
Create / update issues + comments + labels |
read:pipeline |
View pipelines + jobs + logs |
write:pipeline |
Trigger / retry / cancel pipelines |
read:package |
Pull packages from the registry |
write:package |
Push packages |
read:audit |
Read instance-wide audit log (admin-only, confidential client) |
Scope enforcement is additive: a token must hold both the scope and
the underlying ACL permission. Granting write:repo does not grant access
to repos the user cannot already see.
4. Registering an app
- Sign in to Vetrix.
- Visit
/settings/applications. - Click "Register new application".
- Fill in:
- Name (2–60 chars, shown on the consent screen).
- Description (≤1000 chars).
- Homepage URL (optional,
http(s)://). - Logo URL (optional,
http(s)://). - Redirect URIs — one per line. Allowed schemes:
https://…http://localhost[:port]/…,http://127.0.0.1[:port]/…,http://[::1][:port]/…(RFC 8252 §7.3 loopback carve-out — port differences are accepted by Vetrix for these hosts).- Rejected: arbitrary
http://…,javascript:,file:,data:.
- Scopes — least-privilege; you can request fewer at
/authorizetime but never more than the registered set without re-consent. - Client type —
publicorconfidential.
- On save, you receive a
client_id. For confidential clients, the rawclient_secretappears in the response exactly once. Store it now; subsequent reads return<set>.
Per-user cap: oauth2.max_apps_per_user (default 20). The 21st create
returns 409 Conflict with {"error":"too_many_apps"}.
5. Authorization code flow (curl walkthrough)
The walkthrough uses https://api.gitvetrix.com. Substitute the local
dev host (https://api.gitvetrix.test) when working against the
.test stack.
# Compute PKCE locally.
CV=$(openssl rand -base64 32 | tr -d '=' | tr '/+' '_-') # code_verifier
CC=$(printf "%s" "$CV" | openssl dgst -sha256 -binary | openssl base64 | tr -d '=' | tr '/+' '_-')
CID="vetrix_…" # from /settings/applications
CS="…" # only for confidential clients
REDIRECT="http://127.0.0.1:8123/cb"
# Step 1 — send the user to consent.
xdg-open "https://api.gitvetrix.com/api/v1/oauth2/authorize\
?response_type=code\
&client_id=$CID\
&redirect_uri=$REDIRECT\
&scope=read:repo%20read:user\
&state=$(openssl rand -hex 8)\
&code_challenge=$CC\
&code_challenge_method=S256"
# Step 2 — Vetrix redirects to $REDIRECT?code=…&state=… after consent.
# Capture $CODE, then exchange:
curl -u "$CID:$CS" https://api.gitvetrix.com/api/v1/oauth2/token \
-d grant_type=authorization_code \
-d code="$CODE" \
-d redirect_uri="$REDIRECT" \
-d code_verifier="$CV"
# Response:
# {
# "access_token": "vetrix_oat_…",
# "refresh_token": "vetrix_ort_…",
# "token_type": "Bearer",
# "expires_in": 3600,
# "scope": "read:repo read:user"
# }
# Step 3 — call the Vetrix API.
curl -H "Authorization: Bearer $ACCESS" https://api.gitvetrix.com/api/v1/auth/me
# Step 4 — refresh when the access token expires.
curl -u "$CID:$CS" https://api.gitvetrix.com/api/v1/oauth2/token \
-d grant_type=refresh_token \
-d refresh_token="$REFRESH"
# Step 5 — revoke when the user signs out / removes your app.
curl -u "$CID:$CS" https://api.gitvetrix.com/api/v1/oauth2/revoke \
-d token="$ACCESS"
Public clients omit the -u "$CID:$CS" HTTP Basic auth and pass
-d client_id="$CID" in the POST body instead.
6. PKCE S256 math
// Go
verifier := base64.RawURLEncoding.EncodeToString(randomBytes(32))
sum := sha256.Sum256([]byte(verifier))
challenge := base64.RawURLEncoding.EncodeToString(sum[:])
# Python
import base64, hashlib, secrets
verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
challenge = base64.urlsafe_b64encode(
hashlib.sha256(verifier.encode()).digest()
).rstrip(b"=").decode()
// Browser / Node 18+
const buf = crypto.getRandomValues(new Uint8Array(32));
const verifier = btoa(String.fromCharCode(...buf))
.replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
const challenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
.replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
code_challenge_method=S256 is the only accepted method. Vetrix rejects
the legacy plain PKCE method.
7. Refresh-rotation semantics
Every successful refresh exchange:
- Returns a new access token and a new refresh token.
- Marks the old refresh token's
used_at. The old refresh is no longer exchangeable, but its access-token sibling continues to work until its ownexpires_inlapses. - Tracks a
family_idshared by every refresh in the rotation chain.
If a token thief replays an already-used refresh:
- Vetrix revokes the entire family of refresh tokens.
- Vetrix revokes every access token tied to the grant.
- The replay returns
400 Bad Requestwith{"error":"invalid_grant"}. - An
oauth2.refresh_reuse_detectedaudit entry is emitted.
The legitimate user can re-authorize the app at /settings/applications
to restore access. Always store the new refresh token after every
successful exchange.
8. /revoke and /introspect
POST /oauth2/revoke accepts both access (vetrix_oat_…) and refresh
(vetrix_ort_…) tokens, optionally with a token_type_hint. Per RFC 7009
§2.2 the response is always 200 OK — Vetrix never reveals whether the
token existed before the request, so you cannot probe the token space by
revoking randomly-generated values.
POST /oauth2/introspect is restricted to confidential clients (Vetrix
enforces RFC 7662's protected-resource model strictly). Public clients
receive 401 invalid_client. Active tokens return:
{
"active": true,
"scope": "read:repo read:user",
"user_id": "…",
"app_id": "…",
"exp": 1745300800,
"token_type": "Bearer"
}
Inactive / unknown / expired / revoked tokens return:
{ "active": false }
…and nothing else.
9. Error catalogue
| Code | When it occurs | How to recover |
|---|---|---|
invalid_request |
Missing required parameter, double-presented client_secret, bad form | Inspect error_description and fix the request shape |
invalid_client |
Wrong / missing secret, suspended app, public client at /introspect | Re-check credentials; check whether your app was suspended at /admin/oauth2/apps |
invalid_grant |
Code expired / re-used, redirect_uri mismatch, refresh re-used or expired | Restart the consent flow |
unauthorized_client |
Client not allowed to use the requested grant type | Use authorization_code or refresh_token |
unsupported_grant_type |
Unknown grant_type |
Use authorization_code or refresh_token |
invalid_scope |
Unknown scope, or admin-only scope requested by a public client | Check the scope catalog (§3); use a confidential client for admin:* scopes |
access_denied |
User pressed Cancel | Show the user a "permission required" page; offer to retry |
insufficient_scope |
Token lacks the scope required by the called endpoint | Re-request consent with the additional scope; the user will see only the delta |
service_unavailable |
oauth2.server.enabled=false (operator kill switch) |
Wait; Vetrix user JWTs and PATs continue to work |
rate_limited |
60/min/IP at /authorize, 30/min/client_id at /token, etc. | Retry after Retry-After seconds |
10. Token hygiene checklist
- ✅ Treat
vetrix_oat_…andvetrix_ort_…like passwords — never log them, never bake them into client-side bundles. - ✅ Store refresh tokens in a server-side secret store; the bearer is a password-equivalent.
- ✅ Rotate
client_secretperiodically via/settings/applications→ Rotate Secret. Rotation does not invalidate live access tokens; you must also revoke them or wait out their TTL. - ✅ On compromise, revoke the abused token immediately (
/oauth2/revoke) and rotateclient_secret. - ✅ Watch for
oauth2.refresh_reuse_detectedin the admin audit log — one of these means a token thief has replayed a refresh you already used. - ❌ Don't store raw secrets in environment variables on shared CI runners without using the secret-mask feature.
- ❌ Don't share a single
client_idacross multiple deployments — register one app per environment so suspension can target a single tenant.
11. Operator runbook (admins)
| Task | Endpoint / UI |
|---|---|
| Audit every registered app | GET /api/v1/admin/oauth2/apps or /admin/oauth2/apps |
| Suspend a misbehaving app (kill switch) | POST /api/v1/admin/oauth2/apps/{id}/suspend |
| Inspect an app's grants | GET /api/v1/admin/oauth2/apps/{id}/grants |
| Pull the OAuth-only audit trail | GET /api/v1/admin/oauth2/audit |
| Disable the entire OAuth2 server | Set oauth2.server.enabled=false in admin settings |
| Tune access-token TTL | oauth2.access_token_ttl (10m–24h, default 1h) |
| Tune refresh-token TTL | oauth2.refresh_token_ttl (24h–8760h, default 720h) |
| Refuse admin scopes for public clients | oauth2.require_confidential_for_admin_scopes (default true) |
| Cap apps per developer | oauth2.max_apps_per_user (default 20) |
Suspension takes effect on the next request to the OAuth2 access-token
middleware — there is no need to revoke each token individually. Existing
sessions for the suspended app start failing with 401 Unauthorized
within milliseconds of flipping the flag.
12. References
- RFC 6749 — The OAuth 2.0 Authorization Framework
- RFC 6750 — Bearer Token Usage
- RFC 7009 — Token Revocation
- RFC 7636 — PKCE
- RFC 7662 — Token Introspection
- RFC 8252 — OAuth 2.0 for Native Apps (loopback redirect carve-out)
- RFC 9700 — OAuth 2.0 Security Best Current Practice (refresh-token rotation requirements that informed Vetrix's reuse-detection algorithm)
- Internal design:
plans/oauth.md(full Part B specification)
See also
- Connect Claude.ai / Gemini to a Vetrix MCP server over OAuth
— end-user guide for connecting an AI assistant to a repository's MCP server,
covering both the zero-config (DCR) and manual (
client_id) paths.