OAuth Integration
Step-by-step OAuth 2.0 authorization-code integration with required PKCE (S256) for third-party and standalone apps.
OAuth Integration
Use this guide when your app integrates with Aether Labs SSO as an OAuth client. OAuth is the right choice for:
- third-party applications,
- apps on unrelated domains, and
- apps that need an explicit consent screen.
First-party Aether Labs apps on *.aetherlabs.agency usually do not need this — use the
Same-domain SSO guide instead.
Clients are confidential, and PKCE is required
Every OAuth client authenticates at the token endpoint with its client
secret (HTTP Basic or the request body), and the authorization-code flow
requires PKCE with S256. A browser-only app cannot keep a secret, so it
should run the code exchange on its own backend.
Production API: https://sso.aetherlabs.agency
Local dev API: http://localhost:4000Flow summary
Your app
→ redirect the user to: GET /api/oauth/authorize
Aether Labs SSO
→ logs the user in if needed
→ shows the consent screen (GET /api/oauth/consent-info, POST /api/oauth/authorize/approve)
→ redirects back with ?code=…&state=…
Your app / backend
→ exchange code for tokens: POST /api/oauth/token
→ create a product sessionProduction API: https://sso.aetherlabs.agency
Local dev API: http://localhost:4000Supported grants are authorization_code and refresh_token. Supported response
types are code and token (implicit) — use code; the implicit flow is not
recommended.
Step 1 — Create an OAuth client
From the SSO admin console (admin-web → OAuth clients), or with
POST /api/admin/oauth-clients, create a client:
Name: Your Product
Redirect URI: https://yourapp.example.com/oauth/callback
Scopes: openid profile email
Active: trueCopy:
client_id
client_secretImportant:
client_secretis returned once, and stored bcrypt-hashed — it cannot be read back.- Keep it on the backend only — never in a
VITE_,NEXT_PUBLIC_, or any browser bundle. - Rotate it with
POST /api/admin/oauth-clients/:clientId/rotate-secretif it leaks. - If your app is frontend-only, use PKCE and move the token exchange to a backend as soon as you can.
Step 2 — Configure environment variables
Frontend-safe:
VITE_AETHERLABS_OAUTH_ISSUER=https://sso.aetherlabs.agency
VITE_AETHERLABS_OAUTH_CLIENT_ID=your-client-id
VITE_AETHERLABS_OAUTH_REDIRECT_URI=https://yourapp.example.com/oauth/callback
VITE_AETHERLABS_OAUTH_SCOPES=openid profile emailBackend-only:
AETHERLABS_OAUTH_ISSUER=https://sso.aetherlabs.agency
AETHERLABS_OAUTH_CLIENT_ID=your-client-id
AETHERLABS_OAUTH_CLIENT_SECRET=your-client-secret
AETHERLABS_OAUTH_REDIRECT_URI=https://yourapp.example.com/oauth/callbackFor local development, point the issuer at http://localhost:4000 and your local
callback.
Step 3 — Understand PKCE
PKCE (Proof Key for Code Exchange) protects the authorization-code flow: even
if someone steals the code, they cannot exchange it for tokens without the
original code_verifier.
Aether Labs SSO requires PKCE on every authorization request, and only
supports the S256 method — plain is rejected.
Behavior:
/api/oauth/authorizereturnsinvalid_requestunlesscode_challengeandcode_challenge_method=S256are present./api/oauth/tokenreturnsinvalid_grantunless thecode_verifierproduces the original challenge.
Use state as well: PKCE protects the code exchange, state protects your
callback against CSRF.
Step 4 — Generate PKCE values
function base64UrlEncode(buffer: ArrayBuffer) {
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
function randomVerifier(length = 64) {
const bytes = new Uint8Array(length);
crypto.getRandomValues(bytes);
return base64UrlEncode(bytes.buffer);
}
async function sha256(value: string) {
const data = new TextEncoder().encode(value);
return crypto.subtle.digest('SHA-256', data);
}
async function createPkce() {
const codeVerifier = randomVerifier();
const challengeBuffer = await sha256(codeVerifier);
const codeChallenge = base64UrlEncode(challengeBuffer);
return { codeVerifier, codeChallenge };
}Step 5 — Start the login (authorize redirect)
Generate state and PKCE values, store them, then redirect to the authorize endpoint.
export async function loginWithAetherLabs() {
const { codeVerifier, codeChallenge } = await createPkce();
const state = crypto.randomUUID();
sessionStorage.setItem('aetherlabs_code_verifier', codeVerifier);
sessionStorage.setItem('aetherlabs_oauth_state', state);
const params = new URLSearchParams({
response_type: 'code',
client_id: import.meta.env.VITE_AETHERLABS_OAUTH_CLIENT_ID,
redirect_uri: import.meta.env.VITE_AETHERLABS_OAUTH_REDIRECT_URI,
scope: import.meta.env.VITE_AETHERLABS_OAUTH_SCOPES ?? 'openid profile email',
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
window.location.href = `${import.meta.env.VITE_AETHERLABS_OAUTH_ISSUER}/api/oauth/authorize?${params}`;
}The API validates client_id and redirect_uri, logs the user in if needed, and
sends them to the consent screen. The consent screen reads the client and requested
scopes from GET /api/oauth/consent-info and approves with
POST /api/oauth/authorize/approve, which mints a single-use authorization code
(valid 10 minutes) and redirects back to your callback with code and state.
Step 6 — Handle the callback
Your callback route must validate state and recover the PKCE verifier.
export function readOAuthCallback() {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const state = params.get('state');
const error = params.get('error');
if (error) throw new Error(error);
if (!code) throw new Error('Missing authorization code');
const expectedState = sessionStorage.getItem('aetherlabs_oauth_state');
const codeVerifier = sessionStorage.getItem('aetherlabs_code_verifier');
sessionStorage.removeItem('aetherlabs_oauth_state');
sessionStorage.removeItem('aetherlabs_code_verifier');
if (!state || state !== expectedState) throw new Error('Invalid OAuth state');
if (!codeVerifier) throw new Error('Missing PKCE verifier');
return { code, codeVerifier };
}Step 7 — Exchange the code for tokens
Do this on your backend so the client_secret stays private.
export async function exchangeCodeForTokens(code: string, codeVerifier: string) {
const res = await fetch(`${process.env.AETHERLABS_OAUTH_ISSUER}/api/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'authorization_code',
client_id: process.env.AETHERLABS_OAUTH_CLIENT_ID,
client_secret: process.env.AETHERLABS_OAUTH_CLIENT_SECRET,
redirect_uri: process.env.AETHERLABS_OAUTH_REDIRECT_URI,
code,
code_verifier: codeVerifier,
}),
});
if (!res.ok) throw new Error(`Token exchange failed: ${await res.text()}`);
return res.json();
}Successful response:
{
"access_token": "jwt",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "jwt",
"scope": "openid profile email"
}If PKCE fails, the API returns:
{ "error": "invalid_grant", "error_description": "Invalid PKCE verifier" }Step 8 — Create a product session
The recommended production pattern keeps every token on the server:
1. Backend exchanges the code for tokens.
2. Backend stores the tokens securely (or maps them to a session record).
3. Backend sets a secure httpOnly app-session cookie.
4. Frontend uses only that app-session cookie — never the raw refresh token.app.post('/oauth/exchange', async (req, res) => {
const { code, codeVerifier } = req.body;
const tokens = await exchangeCodeForTokens(code, codeVerifier);
res.cookie('appSession', createSession(tokens), {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
});
res.json({ ok: true });
});Development shortcut only
For early local development you can keep tokens in the browser
(sessionStorage). Never ship an app that stores long-lived refresh tokens in
browser storage.
Step 9 — Load the user
With an access token, fetch the OpenID profile:
async function getUserInfo(accessToken: string) {
const res = await fetch('https://sso.aetherlabs.agency/api/oauth/userinfo', {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) throw new Error('Failed to load user info');
return res.json();
}For full Aether Labs roles and permissions, use /api/auth/me instead:
async function getMe(accessToken: string) {
const res = await fetch('https://sso.aetherlabs.agency/api/auth/me', {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) throw new Error('Failed to load user');
return res.json();
}Step 10 — Refresh tokens
Aether Labs SSO rotates refresh tokens — after each refresh, replace the old refresh token with the new one.
async function refreshTokens(refreshToken: string) {
const res = await fetch('https://sso.aetherlabs.agency/api/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'refresh_token',
client_id: process.env.AETHERLABS_OAUTH_CLIENT_ID,
client_secret: process.env.AETHERLABS_OAUTH_CLIENT_SECRET,
refresh_token: refreshToken,
}),
});
if (!res.ok) throw new Error('Refresh failed');
return res.json();
}Step 11 — Logout
Revoke the refresh token, then clear your product session:
async function revokeRefreshToken(refreshToken: string) {
await fetch('https://sso.aetherlabs.agency/api/oauth/revoke', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: process.env.AETHERLABS_OAUTH_CLIENT_ID,
client_secret: process.env.AETHERLABS_OAUTH_CLIENT_SECRET,
token: refreshToken,
token_type_hint: 'refresh_token',
}),
});
}Discovery
The authorization-server metadata is public and safe to fetch at startup:
GET https://sso.aetherlabs.agency/api/oauth/.well-known/oauth-authorization-server{
"issuer": "aetherlabs-sso",
"authorization_endpoint": "https://sso.aetherlabs.agency/api/oauth/authorize",
"token_endpoint": "https://sso.aetherlabs.agency/api/oauth/token",
"userinfo_endpoint": "https://sso.aetherlabs.agency/api/oauth/userinfo",
"revocation_endpoint": "https://sso.aetherlabs.agency/api/oauth/revoke",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"],
"code_challenge_methods_supported": ["S256"],
"scopes_supported": ["openid", "profile", "email", "read", "write", "admin"],
"claims_supported": ["sub", "name", "email", "email_verified", "updated_at"]
}There is no JWKS endpoint — clients authenticate with client_secret_post and
consumer backends verify tokens with the shared secret (or by calling
/api/auth/me).
Manual test with curl
Generate a verifier/challenge pair:
node -e "const c=require('crypto');const v=c.randomBytes(32).toString('base64url');const ch=c.createHash('sha256').update(v).digest('base64url');console.log('CODE_VERIFIER='+v);console.log('CODE_CHALLENGE='+ch);"Open the authorize URL in a browser, approve consent, and copy the code:
https://sso.aetherlabs.agency/api/oauth/authorize?response_type=code&client_id=CLIENT_ID&redirect_uri=https%3A%2F%2Fyourapp.example.com%2Foauth%2Fcallback&scope=openid%20profile%20email&state=test123&code_challenge=CODE_CHALLENGE&code_challenge_method=S256Exchange it:
curl -X POST https://sso.aetherlabs.agency/api/oauth/token \
-H "Content-Type: application/json" \
-d '{
"grant_type": "authorization_code",
"client_id": "CLIENT_ID",
"client_secret": "CLIENT_SECRET",
"redirect_uri": "https://yourapp.example.com/oauth/callback",
"code": "AUTH_CODE",
"code_verifier": "CODE_VERIFIER"
}'The correct verifier returns tokens; a wrong one returns invalid_grant.
Common mistakes
- Using OAuth for same-domain first-party apps when cookie SSO is enough.
- Exposing
client_secretin frontend code. - Skipping
statevalidation. - Skipping PKCE for browser/mobile clients.
- Generating
code_verifierafter the callback instead of before login. - Losing
code_verifierbefore token exchange. - Using normal base64 instead of base64url.
- Not URL-encoding
redirect_uri. - Registering a callback URL that does not exactly match the request.
- Reusing an authorization code (codes are single-use and expire in 10 minutes).
- Continuing to use an old refresh token after rotation.
For AI agents
Implement Aether Labs SSO OAuth authorization-code login with PKCE.
Use:
- issuer: https://sso.aetherlabs.agency (local: http://localhost:4000)
- authorize endpoint: /api/oauth/authorize
- token endpoint: /api/oauth/token
- userinfo endpoint: /api/oauth/userinfo
- revoke endpoint: /api/oauth/revoke
- scopes: openid profile email
- callback route: /oauth/callback
Requirements:
1. Add OAuth config env vars (client_secret backend-only).
2. Login action generates state + PKCE verifier/challenge (S256) and stores them.
3. Redirect to the authorize endpoint with code_challenge_method=S256.
4. Add /oauth/callback that validates state and recovers the verifier.
5. Send code + code_verifier to the backend.
6. Backend exchanges the code at /api/oauth/token using client_secret.
7. Backend creates a secure httpOnly app-session cookie.
8. Add logout that revokes the refresh token and clears the session.
9. Load the user from /api/oauth/userinfo or /api/auth/me.
10. Handle errors: access_denied, invalid state, missing code, token-exchange
failure, Invalid PKCE verifier.
11. Never expose client_secret in frontend code.
12. Refresh tokens rotate — always replace the old one after refresh.Next steps
- Same-domain app instead? See Same-domain SSO.
- Registering and managing the client: OAuth clients.
- Scopes, token shape and lifetimes: Scopes & tokens.
- When something fails: Errors.
- Endpoint details: API reference.