AETHERLABS

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:4000

Flow 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 session
Production API:   https://sso.aetherlabs.agency
Local dev API:    http://localhost:4000

Supported 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:       true

Copy:

client_id
client_secret

Important:

  • client_secret is 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-secret if 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 email

Backend-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/callback

For 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/authorize returns invalid_request unless code_challenge and code_challenge_method=S256 are present.
  • /api/oauth/token returns invalid_grant unless the code_verifier produces 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=S256

Exchange 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_secret in frontend code.
  • Skipping state validation.
  • Skipping PKCE for browser/mobile clients.
  • Generating code_verifier after the callback instead of before login.
  • Losing code_verifier before 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

On this page