Skip to content

OAuth 2.0 / OIDC Flows

Complete guide to AuthVital's OAuth 2.0 and OpenID Connect implementation.

Overview

AuthVital implements the following OAuth 2.0 / OIDC standards:

Standard Support
OAuth 2.0 Authorization Code ✅ Full
PKCE (RFC 7636) ✅ Required for SPAs
OpenID Connect Core 1.0 ✅ Full
OIDC Discovery ✅ Full
Token Refresh ✅ With rotation
Client Credentials ✅ For M2M

Authorization Code Flow with PKCE

This is the recommended flow for Single Page Applications (SPAs) and mobile apps.

Flow Diagram

sequenceDiagram
    participant U as User Browser
    participant C as Your App
    participant A as AuthVital

    Note over C: Generate PKCE pair
    C->>C: code_verifier = random(43-128 chars)
    C->>C: code_challenge = BASE64URL(SHA256(code_verifier))

    C->>A: GET /oauth/authorize?<br/>response_type=code&<br/>client_id=xxx&<br/>redirect_uri=xxx&<br/>code_challenge=xxx&<br/>code_challenge_method=S256&<br/>scope=openid profile email&<br/>state=random

    A->>U: Display Login Page
    U->>A: Enter credentials

    alt User has MFA enabled
        A->>U: MFA Challenge
        U->>A: TOTP Code
    end

    A->>C: 302 Redirect to redirect_uri?<br/>code=AUTH_CODE&state=random

    C->>A: POST /oauth/token<br/>grant_type=authorization_code&<br/>code=AUTH_CODE&<br/>redirect_uri=xxx&<br/>client_id=xxx&<br/>code_verifier=xxx

    A->>A: Verify PKCE:<br/>BASE64URL(SHA256(code_verifier)) == code_challenge

    A->>C: 200 OK<br/>{access_token, refresh_token, id_token}

Step-by-Step Implementation

1. Generate PKCE Challenge

import { generatePKCE } from '@authvital/sdk/server';

// Or implement manually:
function generatePKCE() {
  // Generate random verifier (43-128 characters)
  const verifier = crypto.randomBytes(32).toString('base64url');

  // Create challenge (SHA256 hash of verifier)
  const challenge = crypto
    .createHash('sha256')
    .update(verifier)
    .digest('base64url');

  return { codeVerifier: verifier, codeChallenge: challenge };
}

const { codeVerifier, codeChallenge } = generatePKCE();
// Store codeVerifier securely (session storage, not localStorage)
sessionStorage.setItem('pkce_verifier', codeVerifier);

2. Build Authorization URL

import { buildAuthorizeUrl } from '@authvital/sdk/server';

// Generate both state (CSRF) and nonce (replay protection)
const state = crypto.randomUUID();
const nonce = crypto.randomUUID();

// Store both for validation
sessionStorage.setItem('oauth_state', state);
sessionStorage.setItem('oauth_nonce', nonce);

const authUrl = buildAuthorizeUrl({
  authVitalHost: 'https://auth.example.com',
  clientId: 'your-client-id',
  redirectUri: 'https://app.example.com/callback',
  state,
  nonce,  // Include nonce for ID token validation
  scope: 'openid profile email',
  codeChallenge,
  codeChallengeMethod: 'S256',
});

// Redirect user
window.location.href = authUrl;

Authorization URL Parameters:

Parameter Required Description
response_type Yes Must be code
client_id Yes Your application's client ID
redirect_uri Yes Must match registered URI exactly
code_challenge Yes* PKCE challenge (*required for SPAs)
code_challenge_method Yes* Must be S256
scope No Space-separated scopes (default: openid)
state Recommended CSRF protection
nonce Required for OIDC ID token replay protection - validate in callback
tenant_id No Scope token to specific tenant

3. Handle Callback

// callback.ts - Your /callback route handler
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const state = urlParams.get('state');
const error = urlParams.get('error');

// Check for errors
if (error) {
  console.error('OAuth error:', urlParams.get('error_description'));
  return;
}

// Verify state matches (CSRF protection)
if (state !== sessionStorage.getItem('oauth_state')) {
  throw new Error('State mismatch - possible CSRF attack');
}

// Get stored PKCE verifier
const codeVerifier = sessionStorage.getItem('pkce_verifier');

Validate Nonce from ID Token

// In your callback handler, validate the nonce from the ID token
import { decodeJwt } from '@authvital/sdk/server';

const idToken = tokens.id_token;
const claims = decodeJwt(idToken);

const storedNonce = sessionStorage.getItem('oauth_nonce');
if (claims.nonce !== storedNonce) {
  throw new Error('Nonce mismatch - possible replay attack');
}
sessionStorage.removeItem('oauth_nonce');

4. Exchange Code for Tokens

import { exchangeCodeForTokens } from '@authvital/sdk/server';

const tokens = await exchangeCodeForTokens({
  authVitalHost: 'https://auth.yourapp.com',
  clientId: 'your-client-id',
  code,
  codeVerifier,
  redirectUri: 'https://yourapp.com/callback',
});

// tokens contains:
// - access_token: JWT for API calls
// - refresh_token: For getting new access tokens
// - id_token: User identity (OIDC)
// - expires_in: Token lifetime in seconds
// - token_type: "Bearer"

Token Request (Raw HTTP):

POST /oauth/token HTTP/1.1
Host: auth.yourapp.com
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=AUTH_CODE_HERE
&redirect_uri=https://yourapp.com/callback
&client_id=your-client-id
&code_verifier=YOUR_PKCE_VERIFIER

Tenant-Scoped Tokens

For multi-tenant applications, you can request tokens scoped to a specific tenant:

const authorizeUrl = buildAuthorizeUrl({
  authVitalHost: 'https://auth.yourapp.com',
  clientId: 'your-client-id',
  redirectUri: 'https://yourapp.com/callback',
  codeChallenge,
  scope: 'openid profile email',
  tenantId: 'tenant-uuid-here', // Scope to specific tenant
});

The resulting token will include: - tenant_id: The tenant UUID - tenant_slug: The tenant's URL-safe identifier - tenant_role: User's role within that tenant - app_roles: Application-specific roles for this tenant - app_permissions: Granted permissions

Token Refresh

Access tokens expire (default: 1 hour). Use the SDK to refresh tokens:

import { refreshAccessToken } from '@authvital/sdk/server';

const newTokens = await refreshAccessToken({
  authVitalHost: process.env.AUTHVITAL_HOST!,
  clientId: process.env.AUTHVITAL_CLIENT_ID!,
  refreshToken: storedRefreshToken,
});

// IMPORTANT: AuthVital rotates refresh tokens - always store the new one!
storeRefreshToken(newTokens.refresh_token);
storeAccessToken(newTokens.access_token);

Client Credentials Flow (M2M)

For machine-to-machine authentication (backend services, cron jobs), the SDK handles this automatically:

import { createAuthVital } from '@authvital/sdk/server';

// Configure with client secret for M2M
const authvital = createAuthVital({
  authVitalHost: process.env.AUTHVITAL_HOST!,
  clientId: process.env.AUTHVITAL_CLIENT_ID!,
  clientSecret: process.env.AUTHVITAL_CLIENT_SECRET!,
});

// The SDK automatically uses client_credentials for M2M API calls
// For M2M, pass null for request and specify tenantId explicitly:
const members = await authvital.memberships.listForTenant(null, {
  tenantId: 'tenant-123',
});
const licenses = await authvital.licenses.getTenantOverview(null, {
  tenantId: 'tenant-123',
});

const { access_token } = await response.json();

Requirements: - Application must be type MACHINE (not SPA) - client_secret is required - Tokens are not user-scoped

OIDC Discovery

AuthVital exposes standard OIDC discovery endpoints:

OpenID Configuration

GET /.well-known/openid-configuration

Response:

{
  "issuer": "https://auth.yourapp.com",
  "authorization_endpoint": "https://auth.yourapp.com/oauth/authorize",
  "token_endpoint": "https://auth.yourapp.com/oauth/token",
  "userinfo_endpoint": "https://auth.yourapp.com/oauth/userinfo",
  "jwks_uri": "https://auth.yourapp.com/.well-known/jwks.json",
  "scopes_supported": ["openid", "profile", "email", "offline_access"],
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token", "client_credentials"],
  "token_endpoint_auth_methods_supported": ["client_secret_post", "none"],
  "code_challenge_methods_supported": ["S256"],
  "subject_types_supported": ["public"],
  "id_token_signing_alg_values_supported": ["EdDSA"]
}

JWKS (JSON Web Key Set)

GET /.well-known/jwks.json

Response:

{
  "keys": [
    {
      "kty": "OKP",
      "crv": "Ed25519",
      "x": "...",
      "kid": "key-id-1",
      "use": "sig",
      "alg": "EdDSA"
    }
  ]
}

Scopes

Scope Claims Included
openid sub, iss, aud, exp, iat
profile name, given_name, family_name, picture, locale
email email, email_verified
offline_access Enables refresh tokens

Token Structure

Access Token (JWT)

{
  // Standard claims
  "sub": "user-uuid",           // Subject (user ID)
  "iss": "https://auth.yourapp.com",  // Issuer
  "aud": "your-client-id",      // Audience
  "exp": 1699999999,            // Expiration
  "iat": 1699996399,            // Issued at

  // Profile claims
  "email": "user@example.com",
  "given_name": "Jane",
  "family_name": "Smith",
  "picture": "https://...",

  // Tenant claims (if tenant-scoped)
  "tenant_id": "tenant-uuid",
  "tenant_slug": "acme-corp",

  // Authorization claims
  "app_roles": ["admin", "member"],
  "app_permissions": ["users:read", "users:write"],

  // License claims
  "license": {
    "type": "pro",
    "name": "Pro Plan",
    "features": ["sso", "api-access", "analytics"]
  }
}

ID Token

The ID token contains identity claims only (no authorization):

{
  "sub": "user-uuid",
  "iss": "https://auth.yourapp.com",
  "aud": "your-client-id",
  "exp": 1699999999,
  "iat": 1699996399,
  "nonce": "random-nonce",      // If provided in auth request
  "email": "user@example.com",
  "email_verified": true,
  "given_name": "Jane",
  "family_name": "Smith"
}

Error Handling

Authorization Errors

Errors during authorization redirect to your redirect_uri with error parameters:

https://yourapp.com/callback?error=access_denied&error_description=User+cancelled
Error Description
invalid_request Missing or invalid parameters
unauthorized_client Client not authorized for this grant
access_denied User denied the request
invalid_scope Requested scope is invalid
server_error Unexpected server error

Token Errors

Token endpoint returns JSON errors:

{
  "error": "invalid_grant",
  "error_description": "Authorization code expired or already used"
}
Error Description
invalid_request Missing required parameter
invalid_client Client authentication failed
invalid_grant Invalid code, expired, or PKCE mismatch
unauthorized_client Client not authorized for grant type

Security Best Practices

✅ Do

  • Always use PKCE for SPAs (S256 method)
  • Store tokens securely (memory for access, httpOnly cookies for refresh)
  • Validate state parameter to prevent CSRF
  • Validate nonce in ID tokens to prevent replay attacks
  • Check token expiration before use
  • Handle token refresh proactively

❌ Don't

  • Never store tokens in localStorage (XSS vulnerable)
  • Never expose client_secret to browsers
  • Don't skip PKCE validation
  • Don't ignore token expiration
  • Don't hardcode redirect URIs

Using with the SDK

The SDK handles most of this automatically:

import { AuthVitalProvider, useAuth } from '@authvital/sdk/client';

// Provider handles PKCE, token storage, refresh automatically
<AuthVitalProvider
  authVitalHost="https://auth.yourapp.com"
  clientId="your-client-id"
>
  <App />
</AuthVitalProvider>

// Hook handles the OAuth flow
function LoginButton() {
  const { login, logout, isAuthenticated } = useAuth();

  return isAuthenticated 
    ? <button onClick={logout}>Logout</button>
    : <button onClick={login}>Login</button>;
}