/** * SSO token retrieval for Okta and Azure AD. * Uses OAuth2 ROPC (Resource Owner Password Credentials) flow to get access tokens * without browser-based login. * * Note: ROPC is deprecated in OAuth 2.1 but still supported by Okta and Azure AD * for service accounts and automated testing scenarios. */ export type SsoProvider = 'okta' | 'azure_ad' | 'generic_oidc'; export interface SsoConfig { provider: SsoProvider; /** Okta domain (e.g. 'mycompany.okta.com') or Azure tenant ID */ domain: string; /** OAuth2 client ID */ clientId: string; /** OAuth2 client secret (for client_credentials or confidential ROPC) */ clientSecret?: string; /** Scopes to request (space-separated). Default: 'openid profile' */ scope?: string; } /** * Get an access token using ROPC flow (username + password → token). * Returns the access token string. */ export async function getSsoToken( config: SsoConfig, username: string, password: string ): Promise { let tokenUrl: string; const params: Record = { grant_type: 'password', username, password, scope: config.scope ?? 'openid profile', client_id: config.clientId, }; if (config.clientSecret) { params.client_secret = config.clientSecret; } switch (config.provider) { case 'okta': tokenUrl = `https://${config.domain}/oauth2/v1/token`; break; case 'azure_ad': // domain here is the tenant ID (e.g. XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX or 'mycompany.onmicrosoft.com') tokenUrl = `https://login.microsoftonline.com/${config.domain}/oauth2/v2.0/token`; break; case 'generic_oidc': tokenUrl = config.domain.startsWith('http') ? config.domain : `https://${config.domain}/oauth2/token`; break; default: throw new Error(`Unknown SSO provider: ${config.provider}`); } const res = await fetch(tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(params).toString(), }); if (!res.ok) { const body = await res.text().catch(() => res.statusText); throw new Error(`SSO token request failed (${res.status}): ${body}`); } const data = await res.json() as { access_token?: string; error?: string; error_description?: string }; if (data.error) throw new Error(`SSO error: ${data.error} — ${data.error_description}`); if (!data.access_token) throw new Error('SSO response missing access_token'); return data.access_token; } /** * Get a client credentials token (no user — for service accounts). */ export async function getSsoClientCredentialsToken( config: SsoConfig ): Promise { if (!config.clientSecret) throw new Error('clientSecret required for client credentials flow'); let tokenUrl: string; switch (config.provider) { case 'okta': tokenUrl = `https://${config.domain}/oauth2/v1/token`; break; case 'azure_ad': tokenUrl = `https://login.microsoftonline.com/${config.domain}/oauth2/v2.0/token`; break; default: tokenUrl = `https://${config.domain}/oauth2/token`; } const res = await fetch(tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', client_id: config.clientId, client_secret: config.clientSecret, scope: config.scope ?? 'openid profile', }).toString(), }); if (!res.ok) { const body = await res.text().catch(() => res.statusText); throw new Error(`SSO client credentials failed (${res.status}): ${body}`); } const data = await res.json() as { access_token?: string; error?: string; error_description?: string }; if (data.error) throw new Error(`SSO error: ${data.error} — ${data.error_description}`); if (!data.access_token) throw new Error('SSO response missing access_token'); return data.access_token; }