Skip to main content

OIDC Discovery

Nexus Access Vault uses OIDC Discovery to automatically configure authentication endpoints. The discovery document is fetched from:
https://manager.kappa4.com/.well-known/openid-configuration

Discovery Implementation

From supabase/functions/zitadel-api/index.ts:40-47:
async function getOIDCDiscovery(issuerUrl: string) {
  const response = await fetch(`${issuerUrl}/.well-known/openid-configuration`)
  if (!response.ok) {
    throw new Error(`Failed to fetch OIDC discovery: ${response.status}`)
  }
  return await response.json()
}
This provides the following endpoints:
  • authorization_endpoint - Where users are redirected to authenticate
  • token_endpoint - Where authorization codes are exchanged for tokens
  • userinfo_endpoint - Where user information is retrieved
  • jwks_uri - Public keys for token verification

Complete Authentication Flow

The authentication process follows the OAuth 2.0 Authorization Code flow with PKCE:

Phase 1: Initiate SSO

Code Implementation (src/hooks/useZitadelSSO.ts:52-98):
const initiateSSO = async (configId: string) => {
  setLoading(true);
  try {
    // Generate PKCE parameters
    const { codeVerifier, codeChallenge } = await generatePKCE();
    
    // Generate random state
    const stateArray = new Uint8Array(32);
    crypto.getRandomValues(stateArray);
    const state = btoa(String.fromCharCode(...stateArray))
      .replace(/\+/g, '-')
      .replace(/\//g, '_')
      .replace(/=/g, '');
    
    // Get auth URL from edge function
    const response = await fetch(
      `${import.meta.env.VITE_SUPABASE_URL}/functions/v1/zitadel-api?action=get-auth-url`,
      {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'apikey': import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY,
        },
        body: JSON.stringify({ 
          configId,
          state,
          codeVerifier: codeChallenge, // Send challenge to server
        }),
      }
    );

    const result = await response.json();

    // Store state and code verifier for callback verification
    localStorage.setItem('zitadel_state', state);
    localStorage.setItem('zitadel_nonce', result.nonce || state);
    localStorage.setItem('zitadel_config_id', configId);
    localStorage.setItem('zitadel_code_verifier', codeVerifier);

    // Redirect to Zitadel
    window.location.href = result.authUrl;
  } catch (error) {
    console.error('SSO initiation error:', error);
  }
};

Phase 2: User Authentication

At this point, the user is at Zitadel’s login page. They:
  1. Enter their credentials
  2. Complete MFA if required
  3. Grant consent (if first time)
Zitadel then redirects back to your callback URI with:
  • code - Authorization code
  • state - The same state parameter you sent

Phase 3: Token Exchange

Token Exchange Implementation (supabase/functions/zitadel-api/index.ts:49-87):
async function exchangeCodeForTokens(
  config: ZitadelConfig,
  code: string,
  codeVerifier?: string
) {
  const discovery = await getOIDCDiscovery(config.issuer_url)
  
  const params = new URLSearchParams({
    grant_type: 'authorization_code',
    client_id: config.client_id,
    code,
    redirect_uri: config.redirect_uri,
  })

  if (config.client_secret) {
    params.append('client_secret', config.client_secret)
  }

  if (codeVerifier) {
    params.append('code_verifier', codeVerifier)
  }

  const response = await fetch(discovery.token_endpoint, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body: params,
  })

  if (!response.ok) {
    const error = await response.text()
    console.error('Token exchange error:', error)
    throw new Error(`Token exchange failed: ${response.status}`)
  }

  return await response.json()
}

Phase 4: User Provisioning

The edge function handles user provisioning automatically (supabase/functions/zitadel-api/index.ts:809-928):
case 'sso-callback': {
  // Exchange code for tokens
  const tokens = await exchangeCodeForTokens(config, code, codeVerifier)
  const userInfo = await getUserInfo(config.issuer_url, tokens.access_token)
  
  // Get user groups/roles
  let groups: string[] = []
  if (config.api_token && userInfo.sub) {
    groups = await getUserGroups(config.issuer_url, config.api_token, userInfo.sub)
  }
  
  // Extract claims
  const email = userInfo.email || userInfo.preferred_username
  const orgId = userInfo['urn:zitadel:iam:org:id'] || config.organization_id
  
  // Check if user exists
  let existingUser: { id: string } | null = null
  
  // Method 1: Check by Zitadel identity link
  const { data: zitadelLink } = await supabaseClient
    .from('user_zitadel_identities')
    .select('user_id')
    .eq('zitadel_user_id', userInfo.sub)
    .eq('zitadel_config_id', configId)
    .single()
  
  if (zitadelLink) {
    existingUser = { id: zitadelLink.user_id }
  } else {
    // Method 2: Check by email
    const { data: authUsers } = await supabaseClient.auth.admin.listUsers()
    const matchedUser = authUsers?.users?.find(u => u.email === email)
    if (matchedUser) {
      existingUser = { id: matchedUser.id }
    }
  }
  
  // Create or update user...
}

Configuration Schema

The zitadel_configurations table stores OIDC settings:
CREATE TABLE zitadel_configurations (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  organization_id UUID REFERENCES organizations(id),
  name TEXT NOT NULL,
  issuer_url TEXT NOT NULL,
  client_id TEXT NOT NULL,
  client_secret TEXT,  -- Optional for confidential clients
  redirect_uri TEXT NOT NULL,
  scopes TEXT[] DEFAULT ARRAY['openid', 'profile', 'email'],
  api_token TEXT,  -- Service account PAT
  project_id TEXT,  -- Zitadel project ID
  sync_groups BOOLEAN DEFAULT true,
  is_active BOOLEAN DEFAULT true,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

Configuration Fields

FieldTypeRequiredDescription
issuer_urlTEXTYesZitadel instance URL (e.g., https://manager.kappa4.com)
client_idTEXTYesApplication client ID from Zitadel
client_secretTEXTNoOnly needed for confidential clients (not used with PKCE)
redirect_uriTEXTYesCallback URL where Zitadel sends auth code
scopesTEXT[]YesOIDC scopes to request
api_tokenTEXTNoService account PAT for group sync
project_idTEXTNoZitadel project ID for role filtering
sync_groupsBOOLEANNoEnable automatic group synchronization

Scope Configuration

Standard OIDC Scopes

const scopes = [
  'openid',    // Required - enables OIDC
  'profile',   // User's name and profile info
  'email',     // User's email address
];

Zitadel-Specific Scopes

const zitadelScopes = [
  'urn:zitadel:iam:org:project:id:zitadel:aud',  // Organization and role claims
];
This scope adds the following claims to the ID token:
  • urn:zitadel:iam:org:id - Organization ID
  • urn:zitadel:iam:org:name - Organization name
  • urn:zitadel:iam:org:domain - Organization domain
  • Project role assignments

Claims Mapping

After authentication, claims are extracted from the userinfo response:
// Standard claims
const email = userInfo.email || userInfo.preferred_username
const name = userInfo.name || userInfo.preferred_username
const userId = userInfo.sub  // Zitadel user ID

// Zitadel-specific claims
const orgId = userInfo['urn:zitadel:iam:org:id']
const orgName = userInfo['urn:zitadel:iam:org:domain'] || 
                userInfo['urn:zitadel:iam:org:name']

Security Best Practices

Token Storage: Never store access tokens or refresh tokens in localStorage or sessionStorage. Nexus Access Vault only stores them temporarily during the authentication flow, then relies on Supabase’s secure session management.

State Parameter Validation

Always validate the state parameter to prevent CSRF attacks:
// Before initiating flow
localStorage.setItem('zitadel_state', state);

// In callback handler
const storedState = localStorage.getItem('zitadel_state');
if (storedState !== returnedState) {
  throw new Error('State mismatch - possible CSRF attack');
}
localStorage.removeItem('zitadel_state');

Code Verifier Security

The code verifier must:
  • Be cryptographically random (32 bytes minimum)
  • Never be sent to the authorization endpoint
  • Only be sent during token exchange
  • Be single-use (deleted after exchange)

Nonce Validation

The nonce prevents replay attacks on ID tokens:
// Server generates nonce (index.ts:795-799)
const nonceArray = new Uint8Array(32)
crypto.getRandomValues(nonceArray)
const nonce = Array.from(nonceArray)
  .map(b => b.toString(16).padStart(2, '0'))
  .join('')

Error Handling

Common OIDC Errors

Error CodeMeaningSolution
invalid_requestMalformed requestCheck all required parameters
invalid_clientClient ID not foundVerify client_id matches Zitadel
invalid_grantCode expired/usedRetry authentication
unauthorized_clientWrong auth methodEnsure PKCE is enabled in Zitadel
unsupported_grant_typeWrong grant typeShould be authorization_code

Error Response Example

try {
  const tokens = await exchangeCodeForTokens(config, code, codeVerifier)
} catch (error) {
  // Error responses from token endpoint
  {
    "error": "invalid_grant",
    "error_description": "The provided authorization grant is invalid, expired, or revoked"
  }
}

Testing Configuration

Test your OIDC configuration with the built-in test endpoint:
curl -X POST \
  "${SUPABASE_URL}/functions/v1/zitadel-api?action=test-connection" \
  -H "Content-Type: application/json" \
  -H "apikey: ${SUPABASE_ANON_KEY}" \
  -d '{
    "issuerUrl": "https://manager.kappa4.com",
    "apiToken": "<service-account-token>"
  }'
Expected Response:
{
  "success": true,
  "apiConnected": true,
  "groupCount": 5,
  "message": "Connection successful"
}

Next Steps

Build docs developers (and LLMs) love