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
| Endpoint | URL | Description |
|---|---|---|
| Authorization | https://camino.redbroomsoftware.com/oauth/authorize | Start OAuth flow |
| Token | https://camino.redbroomsoftware.com/oauth/token | Exchange code for tokens |
| UserInfo | https://camino.redbroomsoftware.com/oauth/userinfo | Get user profile |
| Revoke | https://camino.redbroomsoftware.com/oauth/revoke | Revoke tokens |
Implementation Guide
1. Register Your App
Contact the ecosystem admin to register your app and receive:
RBS_CLIENT_ID- Your OAuth client IDRBS_CLIENT_SECRET- Your OAuth client secret
2. Environment Variables
env
RBS_CLIENT_ID=your_oauth_client_id
RBS_CLIENT_SECRET=your_oauth_client_secret3. 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
| Scope | Description |
|---|---|
openid | Required for OIDC |
profile | Name, picture |
email | Email address |
organization | Org ID and name |
Security Best Practices
- Always use PKCE - Prevents authorization code interception
- Validate state - Prevents CSRF attacks
- Use secure cookies -
httpOnly,secure,sameSite: 'lax' - Short cookie lifetime - 10 minutes max for OAuth cookies
- Clear cookies after use - Delete immediately after callback
Reference Implementations
| App | Status | Files |
|---|---|---|
| Constanza | ✅ Implemented | src/routes/auth/rbs/*, src/lib/services/rbs-auth.service.ts |
| La Hoja | ✅ Implemented | src/routes/auth/rbs/* |
| Caracol | ✅ Implemented | src/routes/auth/rbs/* |
| Cosmos Pet | ✅ Implemented | src/routes/auth/rbs/* |
| Colectiva | ✅ Implemented | src/routes/auth/rbs/* |
| Plenura | ✅ Implemented | Ecosystem bridge |
| Mancha | ✅ Implemented | src/routes/auth/rbs/* |
| Agora | ✅ Implemented | API key ecosystem |
For questions, contact the ecosystem team.