import { Env } from '@env'; import axios from 'axios'; import queryString from 'query-string'; import { logger } from '@/lib/logging'; import { applyClientHeaders } from '@/lib/mfa/client-app'; import { type MfaChallenge, parseMethods } from '@/lib/mfa/types'; import { getBaseApiUrl } from '../storage/app'; import { classifyLoginFailure } from './login-errors'; import type { AuthResponse, LoginCredentials, LoginResponse, SsoLoginCredentials } from './types'; const authApi = axios.create({ baseURL: getBaseApiUrl(), // Axios defaults to no timeout — a hung /connect/token call would otherwise // pin the single-flight refresh promise and stall every queued 401 retry. A // timeout surfaces as a transient failure (no response), never a logout. timeout: 15000, headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, }); // Dynamically update baseURL on every request to support // custom server URL changes (e.g. self-hosted environments) authApi.interceptors.request.use((config) => { config.baseURL = getBaseApiUrl(); // The app and installation headers bind a sign-in transaction and the session it creates to this app, and mark a shared // vehicle installation (passkey plan sections 10.4 and 10.5). applyClientHeaders(config.headers); return config; }); interface OAuthErrorBody { error?: string; mfa_transaction?: string; mfa_setup_transaction?: string; mfa_methods?: string; mfa_enrolled?: string; mfa_preferred?: string; mfa_expires_in?: number; login_error?: unknown; } const oauthErrorBody = (error: unknown): OAuthErrorBody => { const data = (error as { response?: { data?: unknown } })?.response?.data; return typeof data === 'object' && data !== null ? (data as OAuthErrorBody) : {}; }; /** The login transaction the token endpoint started, when it started one (transaction flow on, passkey workbook section 7.1). */ export const loginTransactionFrom = (body: OAuthErrorBody, source: MfaChallenge['source']): LoginResponse['mfaTransaction'] | undefined => { const expiresAt = typeof body.mfa_expires_in === 'number' ? Date.now() + body.mfa_expires_in * 1000 : null; if (body.error === 'mfa_required' && body.mfa_transaction) { const methods = parseMethods(body.mfa_methods); const enrolled = parseMethods(body.mfa_enrolled); const preferred = parseMethods(body.mfa_preferred)[0] ?? null; return { secret: body.mfa_transaction, challenge: { kind: 'verify', methods, enrolled, preferred, expiresAt, source } }; } if (body.error === 'mfa_enrollment_required' && body.mfa_setup_transaction) { return { secret: body.mfa_setup_transaction, challenge: { kind: 'setup', methods: ['totp'], enrolled: [], preferred: 'totp', expiresAt, source } }; } return undefined; }; export const loginRequest = async (credentials: LoginCredentials): Promise => { try { const data = queryString.stringify({ grant_type: 'password', username: credentials.username, password: credentials.password, // A second factor continues on a login transaction; a server without one answers the older way (a code resent // with the password), which the totp_code below still serves. ...(credentials.otpCode ? { totp_code: credentials.otpCode.trim() } : { mfa_flow: 'transaction' }), scope: Env.IS_MOBILE_APP === 'true' ? 'openid profile offline_access mobile' : 'openid profile offline_access', }); const response = await authApi.post('/connect/token', data); if (response.status === 200) { logger.info({ message: 'Login successful', }); return { successful: true, message: 'Login successful', authResponse: response.data, }; } else { // Never log the response object — it carries config.data with the // urlencoded username/password body. logger.error({ message: 'Login failed', context: { status: response.status }, }); return { successful: false, message: 'login_failed', authResponse: null, failure: { code: 'login_failed' }, }; } } catch (error) { // The OAuth error body distinguishes the 2FA challenge from a bad password. Neither the // password, the transaction nor any code is ever logged. const body = oauthErrorBody(error); const mfaTransaction = loginTransactionFrom(body, 'password'); if (mfaTransaction) { logger.info({ message: 'Login continues on a second-factor transaction', context: { kind: mfaTransaction.challenge.kind, methods: mfaTransaction.challenge.methods }, }); return { successful: false, message: 'Additional verification is required', authResponse: null, mfaRequired: true, mfaTransaction }; } if (body.error === 'mfa_enrollment_required') { return { successful: false, message: 'mfa_enrollment_required', authResponse: null, enrollmentRequired: true }; } const oauthError = body.error; if (oauthError === 'mfa_required' || oauthError === 'invalid_totp') { logger.info({ message: 'Login requires two-factor code', context: { invalidOtp: oauthError === 'invalid_totp' }, }); return { successful: false, message: 'Two-factor authentication required', authResponse: null, mfaRequired: true, invalidOtp: oauthError === 'invalid_totp', }; } const failure = classifyLoginFailure(error); // The server's login_error is a fixed set of reason codes, never free text. const loginError = typeof body.login_error === 'string' && /^[a-z_]{1,40}$/.test(body.login_error) ? body.login_error : undefined; // The sanitizer reduces axios errors to safe summaries (no request bodies). // Never include the username here — it is frequently an email address. logger.error({ message: 'Login failed', context: { error, loginFailure: failure.code, ...(loginError ? { loginError } : {}) }, }); // Return a failed response instead of throwing; the code carries the reason the login screen explains. return { successful: false, message: failure.code, authResponse: null, failure, }; } }; /** * Exchanges a finished login transaction for tokens (passkey workbook section 7.1): the completion code is single-use, * bound to this transaction and app, and lives about a minute. A lost response means signing in again. This request * creates the session, so it carries the shared-installation and device headers too (the interceptor adds them). */ export const completionGrantRequest = async (transaction: string, completionCode: string): Promise => { const data = queryString.stringify({ grant_type: 'urn:resgrid:params:oauth:grant-type:mfa_completion', transaction, completion_code: completionCode, }); try { const response = await authApi.post('/connect/token', data); logger.info({ message: 'Login transaction completed' }); return response.data; } catch (error) { // The sanitizer reduces axios errors to safe summaries: neither the transaction nor the completion code is logged. logger.error({ message: 'Login transaction completion failed', context: { error } }); throw error; } }; export const refreshTokenRequest = async (refreshToken: string): Promise => { try { const data = queryString.stringify({ grant_type: 'refresh_token', refresh_token: refreshToken, scope: '', }); const response = await authApi.post('/connect/token', data); logger.info({ message: 'Token refresh successful', }); return response.data; } catch (error) { // The response interceptor (api/common/client.tsx) classifies this failure and // logs the actionable outcome (transient vs. credential rejection). Keep this at // warn so a transient refresh failure isn't duplicated to Sentry as an error. logger.warn({ message: 'Token refresh request failed', context: { error }, }); throw error; } }; /** * Exchange an external IdP token (OIDC id_token or SAML SAMLResponse) for * Resgrid access/refresh tokens via POST /api/v4/connect/external-token. */ export const ssoExternalTokenRequest = async (credentials: SsoLoginCredentials): Promise => { try { const data = queryString.stringify({ provider: credentials.provider, external_token: credentials.externalToken, username: credentials.username, ...(credentials.departmentToken ? { department_token: credentials.departmentToken } : {}), // Accounts with Resgrid 2FA enabled must supply the current authenticator code even via SSO. ...(credentials.otpCode ? { totp_code: credentials.otpCode.trim() } : {}), scope: Env.IS_MOBILE_APP === 'true' ? 'openid email profile offline_access mobile' : 'openid email profile offline_access', }); const response = await authApi.post('/connect/external-token', data); if (response.status === 200) { logger.info({ message: 'SSO external token exchange successful', context: { provider: credentials.provider }, }); return { successful: true, message: 'SSO login successful', authResponse: response.data, }; } logger.error({ message: 'SSO external token exchange failed', context: { status: response.status, provider: credentials.provider }, }); return { successful: false, message: 'SSO login failed', authResponse: null }; } catch (error) { // The error body distinguishes the 2FA challenge from a real failure. Neither the IdP // token nor any code is ever logged. const oauthError = (error as { response?: { data?: { error?: string } } })?.response?.data?.error; if (oauthError === 'mfa_required' || oauthError === 'invalid_totp') { logger.info({ message: 'SSO login requires two-factor code', context: { provider: credentials.provider, invalidOtp: oauthError === 'invalid_totp' }, }); return { successful: false, message: 'Two-factor authentication required', authResponse: null, mfaRequired: true, invalidOtp: oauthError === 'invalid_totp', }; } logger.error({ message: 'SSO external token exchange error', context: { error, provider: credentials.provider }, }); throw error; } };