Skip to content

RBS Ecosystem SSO Integration

Status: IMPLEMENTED Provider: Camino OAuth2


Overview

Camino serves as the OAuth2 identity provider for the RBS ecosystem. All apps can offer "Login with Red Broom" to unify user identity across platforms.

Architecture

┌─────────────────────────────────────────────────────────────────┐
│                        USER                                     │
└─────────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────┐
│                    CLIENT APP (e.g., Constanza)                 │
│                                                                 │
│  1. User clicks "Login with Red Broom"                          │
│  2. App generates state + PKCE                                  │
│  3. Redirect to Camino /oauth/authorize                         │
└─────────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────┐
│                    CAMINO (OAuth2 Provider)                     │
│                                                                 │
│  4. User authenticates (if not logged in)                       │
│  5. User consents to scopes                                     │
│  6. Redirect back with authorization code                       │
└─────────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────┐
│                    CLIENT APP (callback)                        │
│                                                                 │
│  7. Exchange code for tokens                                    │
│  8. Fetch user info                                             │
│  9. Create local session (e.g., Firebase custom token)          │
└─────────────────────────────────────────────────────────────────┘

Endpoints

EndpointURLDescription
Authorizationhttps://camino.redbroomsoftware.com/oauth/authorizeStart OAuth flow
Tokenhttps://camino.redbroomsoftware.com/oauth/tokenExchange code for tokens
UserInfohttps://camino.redbroomsoftware.com/oauth/userinfoGet user profile
Revokehttps://camino.redbroomsoftware.com/oauth/revokeRevoke tokens

Implementation Guide

1. Register Your App

Contact the ecosystem admin to register your app and receive:

  • RBS_CLIENT_ID - Your OAuth client ID
  • RBS_CLIENT_SECRET - Your OAuth client secret

2. Environment Variables

env
RBS_CLIENT_ID=your_oauth_client_id
RBS_CLIENT_SECRET=your_oauth_client_secret

3. Create OAuth Service

Reference implementation from Constanza (src/lib/services/rbs-auth.service.ts):

typescript
interface RBSAuthConfig {
  clientId: string;
  clientSecret: string;
  redirectUri: string;
  authorizationUrl?: string;
  tokenUrl?: string;
  userInfoUrl?: string;
}

export class RBSAuth {
  private config: Required<RBSAuthConfig>;

  constructor(config: RBSAuthConfig) {
    this.config = {
      authorizationUrl: 'https://camino.redbroomsoftware.com/oauth/authorize',
      tokenUrl: 'https://camino.redbroomsoftware.com/oauth/token',
      userInfoUrl: 'https://camino.redbroomsoftware.com/oauth/userinfo',
      ...config
    };
  }

  // Generate PKCE challenge
  async generatePKCE(): Promise<{ codeVerifier: string; codeChallenge: string }> {
    const verifier = generateRandomString(64);
    const encoder = new TextEncoder();
    const data = encoder.encode(verifier);
    const digest = await crypto.subtle.digest('SHA-256', data);
    const challenge = base64URLEncode(digest);
    return { codeVerifier: verifier, codeChallenge: challenge };
  }

  // Build authorization URL
  async getAuthorizationUrl(options: {
    state: string;
    usePKCE?: boolean;
    prompt?: 'login' | 'consent' | 'none';
  }): Promise<{ url: string; pkce?: { codeVerifier: string } }> {
    const params = new URLSearchParams({
      response_type: 'code',
      client_id: this.config.clientId,
      redirect_uri: this.config.redirectUri,
      scope: 'openid profile email organization',
      state: options.state
    });

    let pkce: { codeVerifier: string } | undefined;
    if (options.usePKCE) {
      const { codeVerifier, codeChallenge } = await this.generatePKCE();
      params.set('code_challenge', codeChallenge);
      params.set('code_challenge_method', 'S256');
      pkce = { codeVerifier };
    }

    if (options.prompt) {
      params.set('prompt', options.prompt);
    }

    return {
      url: `${this.config.authorizationUrl}?${params.toString()}`,
      pkce
    };
  }

  // Exchange authorization code for tokens
  async exchangeCode(code: string, codeVerifier?: string): Promise<TokenResponse> {
    const body = new URLSearchParams({
      grant_type: 'authorization_code',
      code,
      redirect_uri: this.config.redirectUri,
      client_id: this.config.clientId,
      client_secret: this.config.clientSecret
    });

    if (codeVerifier) {
      body.set('code_verifier', codeVerifier);
    }

    const response = await fetch(this.config.tokenUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: body.toString()
    });

    if (!response.ok) {
      throw new Error('Token exchange failed');
    }

    return response.json();
  }

  // Get user info
  async getUserInfo(accessToken: string): Promise<UserInfo> {
    const response = await fetch(this.config.userInfoUrl, {
      headers: { Authorization: `Bearer ${accessToken}` }
    });

    if (!response.ok) {
      throw new Error('Failed to get user info');
    }

    return response.json();
  }
}

4. Create Routes

Login Initiation (/auth/rbs/+server.ts)

typescript
import { redirect, error } from '@sveltejs/kit';
import { RBSAuth, generateState } from '$lib/services/rbs-auth.service';
import { env } from '$env/dynamic/private';

export const GET = async ({ cookies, url }) => {
  const returnTo = url.searchParams.get('returnTo') || '/dashboard';

  if (!env.RBS_CLIENT_ID || !env.RBS_CLIENT_SECRET) {
    throw error(500, 'RBS SSO not configured');
  }

  const auth = new RBSAuth({
    clientId: env.RBS_CLIENT_ID,
    clientSecret: env.RBS_CLIENT_SECRET,
    redirectUri: `${appUrl}/auth/rbs/callback`
  });

  const state = generateState();
  const { url: authUrl, pkce } = await auth.getAuthorizationUrl({
    state,
    usePKCE: true
  });

  // Store state for CSRF protection
  cookies.set('rbs_oauth_state', state, {
    path: '/',
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    maxAge: 60 * 10
  });

  // Store PKCE verifier
  if (pkce) {
    cookies.set('rbs_oauth_verifier', pkce.codeVerifier, {
      path: '/',
      httpOnly: true,
      secure: true,
      sameSite: 'lax',
      maxAge: 60 * 10
    });
  }

  // Store return URL
  cookies.set('rbs_oauth_return', returnTo, {
    path: '/',
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    maxAge: 60 * 10
  });

  throw redirect(302, authUrl);
};

Callback Handler (/auth/rbs/callback/+server.ts)

typescript
export const GET = async ({ url, cookies }) => {
  // Validate state
  const state = url.searchParams.get('state');
  const savedState = cookies.get('rbs_oauth_state');
  if (!validateState(state, savedState)) {
    throw error(400, 'Invalid state parameter');
  }

  const code = url.searchParams.get('code');
  const codeVerifier = cookies.get('rbs_oauth_verifier');
  const returnTo = cookies.get('rbs_oauth_return') || '/dashboard';

  // Clear OAuth cookies
  cookies.delete('rbs_oauth_state', { path: '/' });
  cookies.delete('rbs_oauth_verifier', { path: '/' });
  cookies.delete('rbs_oauth_return', { path: '/' });

  // Exchange code for tokens
  const tokens = await auth.exchangeCode(code, codeVerifier);

  // Get user info
  const userInfo = await auth.getUserInfo(tokens.access_token);

  // Create local session (Firebase example)
  const firebaseToken = await firebaseAdmin.createCustomToken(userInfo.sub, {
    email: userInfo.email,
    rbsUserId: userInfo.sub,
    rbsOrgId: userInfo.organization_id
  });

  // Redirect to client with token
  throw redirect(302, `/auth/rbs/callback?firebase_token=${firebaseToken}&return_to=${returnTo}`);
};

5. Add Login Button

svelte
<a
  href="/auth/rbs"
  class="w-full flex items-center justify-center gap-2 bg-gray-900 hover:bg-gray-800 text-white font-semibold py-3 rounded-lg"
>
  <svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
    <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z"/>
  </svg>
  Iniciar sesion con Red Broom
</a>

User Info Response

typescript
interface UserInfo {
  sub: string;              // Ecosystem user ID
  email: string;
  email_verified: boolean;
  name: string;
  given_name?: string;
  family_name?: string;
  picture?: string;
  phone_number?: string;
  organization_id: string;  // RBS org ID
  organization_name: string;
}

Account Linking

When a user logs in via SSO, check if they already have a local account (by email or phone). Link to existing account to preserve their data:

typescript
let uid = userInfo.sub;  // Default: use RBS ID

// Check existing account
try {
  const existing = await firebaseAdmin.getUserByEmail(userInfo.email);
  uid = existing.uid;  // Use existing UID
} catch {
  // New user
}

Scopes

ScopeDescription
openidRequired for OIDC
profileName, picture
emailEmail address
organizationOrg ID and name

Security Best Practices

  1. Always use PKCE - Prevents authorization code interception
  2. Validate state - Prevents CSRF attacks
  3. Use secure cookies - httpOnly, secure, sameSite: 'lax'
  4. Short cookie lifetime - 10 minutes max for OAuth cookies
  5. Clear cookies after use - Delete immediately after callback

Reference Implementations

AppStatusFiles
Constanza✅ Implementedsrc/routes/auth/rbs/*, src/lib/services/rbs-auth.service.ts
La Hoja✅ Implementedsrc/routes/auth/rbs/*
Caracol✅ Implementedsrc/routes/auth/rbs/*
Cosmos Pet✅ Implementedsrc/routes/auth/rbs/*
Colectiva✅ Implementedsrc/routes/auth/rbs/*
Plenura✅ ImplementedEcosystem bridge
Mancha✅ Implementedsrc/routes/auth/rbs/*
Agora✅ ImplementedAPI key ecosystem

For questions, contact the ecosystem team.

Red Broom Software Ecosystem