diff --git a/__mocks__/expo-auth-session.ts b/__mocks__/expo-auth-session.ts
new file mode 100644
index 00000000..7e3ed01b
--- /dev/null
+++ b/__mocks__/expo-auth-session.ts
@@ -0,0 +1,21 @@
+// Mock for expo-auth-session
+const mockExchangeCodeAsync = jest.fn();
+const mockMakeRedirectUri = jest.fn(() => 'resgridunit://auth/callback');
+const mockUseAutoDiscovery = jest.fn(() => ({
+ authorizationEndpoint: 'https://idp.example.com/authorize',
+ tokenEndpoint: 'https://idp.example.com/token',
+}));
+const mockUseAuthRequest = jest.fn(() => [
+ { codeVerifier: 'test-verifier' },
+ null,
+ jest.fn(),
+]);
+
+module.exports = {
+ makeRedirectUri: mockMakeRedirectUri,
+ useAutoDiscovery: mockUseAutoDiscovery,
+ useAuthRequest: mockUseAuthRequest,
+ exchangeCodeAsync: mockExchangeCodeAsync,
+ ResponseType: { Code: 'code' },
+ __esModule: true,
+};
diff --git a/__mocks__/expo-web-browser.ts b/__mocks__/expo-web-browser.ts
new file mode 100644
index 00000000..4a9f145c
--- /dev/null
+++ b/__mocks__/expo-web-browser.ts
@@ -0,0 +1,13 @@
+// Mock for expo-web-browser
+const maybeCompleteAuthSession = jest.fn(() => ({ type: 'success' }));
+const openBrowserAsync = jest.fn(() => Promise.resolve({ type: 'dismiss' }));
+const openAuthSessionAsync = jest.fn(() => Promise.resolve({ type: 'dismiss' }));
+const dismissBrowser = jest.fn();
+
+module.exports = {
+ maybeCompleteAuthSession,
+ openBrowserAsync,
+ openAuthSessionAsync,
+ dismissBrowser,
+ __esModule: true,
+};
diff --git a/app.config.ts b/app.config.ts
index 39b4ff62..cccf848b 100644
--- a/app.config.ts
+++ b/app.config.ts
@@ -50,6 +50,8 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
ITSAppUsesNonExemptEncryption: false,
UIViewControllerBasedStatusBarAppearance: false,
NSBluetoothAlwaysUsageDescription: 'Allow Resgrid Unit to connect to bluetooth devices for PTT.',
+ // Allow the app to open its own custom-scheme deep links (needed for SSO callbacks)
+ LSApplicationQueriesSchemes: ['resgridunit'],
},
entitlements: {
...((Env.APP_ENV === 'production' || Env.APP_ENV === 'internal') && {
@@ -71,6 +73,15 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
softwareKeyboardLayoutMode: 'pan',
package: Env.PACKAGE,
googleServicesFile: 'google-services.json',
+ // Register the ResgridUnit:// deep-link scheme so OIDC / SAML callbacks are routed back here
+ intentFilters: [
+ {
+ action: 'VIEW',
+ autoVerify: false,
+ data: [{ scheme: 'resgridunit' }],
+ category: ['BROWSABLE', 'DEFAULT'],
+ },
+ ],
permissions: [
'android.permission.WAKE_LOCK',
'android.permission.RECORD_AUDIO',
@@ -107,6 +118,8 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
'expo-localization',
'expo-router',
['react-native-edge-to-edge'],
+ 'expo-web-browser',
+ 'expo-secure-store',
[
'@rnmapbox/maps',
{
diff --git a/package.json b/package.json
index f06eae7d..ff1f45f0 100644
--- a/package.json
+++ b/package.json
@@ -116,9 +116,11 @@
"expo-application": "~6.1.5",
"expo-asset": "~11.1.7",
"expo-audio": "~0.4.9",
+ "expo-auth-session": "~6.2.1",
"expo-av": "~15.1.7",
"expo-build-properties": "~0.14.8",
"expo-constants": "~17.1.8",
+ "expo-crypto": "~14.1.5",
"expo-dev-client": "~5.2.4",
"expo-device": "~7.1.4",
"expo-document-picker": "~13.1.6",
@@ -134,11 +136,13 @@
"expo-navigation-bar": "~4.2.8",
"expo-router": "~5.1.11",
"expo-screen-orientation": "~8.1.7",
+ "expo-secure-store": "~14.2.4",
"expo-sharing": "~13.1.5",
"expo-splash-screen": "~0.30.10",
"expo-status-bar": "~2.2.3",
"expo-system-ui": "~5.0.11",
"expo-task-manager": "~13.1.6",
+ "expo-web-browser": "~14.2.0",
"geojson": "~0.5.0",
"i18next": "~23.14.0",
"livekit-client": "^2.15.7",
diff --git a/src/app/login/__tests__/index.test.tsx b/src/app/login/__tests__/index.test.tsx
index 6515f2b2..9eeb0462 100644
--- a/src/app/login/__tests__/index.test.tsx
+++ b/src/app/login/__tests__/index.test.tsx
@@ -13,6 +13,37 @@ jest.mock('expo-router', () => ({
}),
}));
+// Mock expo-linking (used for SAML deep-link handling)
+jest.mock('expo-linking', () => ({
+ addEventListener: jest.fn(() => ({ remove: jest.fn() })),
+ parse: jest.fn(() => ({ queryParams: {} })),
+}));
+
+// Mock SSO discovery service
+jest.mock('@/services/sso-discovery', () => ({
+ fetchSsoConfigForUser: jest.fn(() => Promise.resolve({ config: null, userExists: false })),
+}));
+
+// Mock OIDC hook
+jest.mock('@/hooks/use-oidc-login', () => ({
+ useOidcLogin: jest.fn(() => ({
+ request: null,
+ response: null,
+ promptAsync: jest.fn(),
+ exchangeForResgridToken: jest.fn(),
+ discovery: null,
+ })),
+}));
+
+// Mock SAML hook
+jest.mock('@/hooks/use-saml-login', () => ({
+ useSamlLogin: jest.fn(() => ({
+ startSamlLogin: jest.fn(),
+ handleDeepLink: jest.fn(),
+ isSamlCallback: jest.fn(() => false),
+ })),
+}));
+
// Mock UI components
jest.mock('@/components/ui', () => {
const React = require('react');
@@ -131,6 +162,7 @@ describe('Login', () => {
// Set default mock return values
mockUseAuth.mockReturnValue({
login: jest.fn(),
+ ssoLogin: jest.fn(),
status: 'idle',
error: null,
isAuthenticated: false,
@@ -182,6 +214,7 @@ describe('Login', () => {
it('shows error modal when status is error', () => {
mockUseAuth.mockReturnValue({
login: jest.fn(),
+ ssoLogin: jest.fn(),
status: 'error',
error: 'Invalid credentials',
isAuthenticated: false,
@@ -196,6 +229,7 @@ describe('Login', () => {
it('redirects to app when authenticated', async () => {
mockUseAuth.mockReturnValue({
login: jest.fn(),
+ ssoLogin: jest.fn(),
status: 'signedIn',
error: null,
isAuthenticated: true,
@@ -226,6 +260,7 @@ describe('Login', () => {
const mockLogin = jest.fn();
mockUseAuth.mockReturnValue({
login: mockLogin,
+ ssoLogin: jest.fn(),
status: 'idle',
error: null,
isAuthenticated: false,
diff --git a/src/app/login/index.tsx b/src/app/login/index.tsx
index 63abcab1..b0a79964 100644
--- a/src/app/login/index.tsx
+++ b/src/app/login/index.tsx
@@ -17,6 +17,7 @@ import { LoginForm } from './login-form';
export default function Login() {
const [isErrorModalVisible, setIsErrorModalVisible] = useState(false);
const [showServerUrl, setShowServerUrl] = useState(false);
+
const { t } = useTranslation();
const { trackEvent } = useAnalytics();
const router = useRouter();
@@ -32,35 +33,34 @@ export default function Login() {
useEffect(() => {
if (status === 'signedIn' && isAuthenticated) {
- logger.info({
- message: 'Login successful, redirecting to home',
- });
+ logger.info({ message: 'Login successful, redirecting to home' });
router.push('/(app)');
}
}, [status, isAuthenticated, router]);
useEffect(() => {
if (status === 'error') {
- logger.error({
- message: 'Login failed',
- context: { error },
- });
+ logger.error({ message: 'Login failed', context: { error } });
setIsErrorModalVisible(true);
}
}, [status, error]);
+ // ── Local login ───────────────────────────────────────────────────────────
const onSubmit: LoginFormProps['onSubmit'] = async (data) => {
- logger.info({
- message: 'Starting Login (button press)',
- context: { username: data.username },
- });
+ logger.info({ message: 'Starting Login (button press)', context: { username: data.username } });
await login({ username: data.username, password: data.password });
};
return (
<>
- setShowServerUrl(true)} />
+ setShowServerUrl(true)}
+ onSsoPress={() => router.push('/login/sso')}
+ />
.string({
required_error: 'Password is required',
})
- .min(6, 'Password must be at least 6 characters'),
+ .min(1, 'Password is required'),
});
const loginFormSchema = createLoginFormSchema();
@@ -40,14 +40,17 @@ export type LoginFormProps = {
isLoading?: boolean;
error?: string;
onServerUrlPress?: () => void;
+ /** Called when the user taps "Sign In with SSO" to navigate to the SSO login page */
+ onSsoPress?: () => void;
};
-export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = undefined, onServerUrlPress }: LoginFormProps) => {
+export const LoginForm = ({ onSubmit = () => { }, isLoading = false, error = undefined, onServerUrlPress, onSsoPress }: LoginFormProps) => {
const { colorScheme } = useColorScheme();
const { t } = useTranslation();
const {
control,
handleSubmit,
+ getValues,
formState: { errors },
} = useForm({
resolver: zodResolver(loginFormSchema),
@@ -60,9 +63,7 @@ export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = unde
const [showPassword, setShowPassword] = useState(false);
const handleState = () => {
- setShowPassword((showState) => {
- return !showState;
- });
+ setShowPassword((showState) => !showState);
};
const handleKeyPress = () => {
Keyboard.dismiss();
@@ -74,12 +75,11 @@ export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = unde
- Sign In
-
-
- To login in to the Resgrid Unit app, please enter your username and password. Resgrid Unit is an app designed to interface between a Unit (apparatus, team, etc) and the Resgrid system.
-
+ {t('login.title')}
+ {t('login.subtitle')}
+
+ {/* Username */}
{t('login.username')}
@@ -91,10 +91,10 @@ export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = unde
rules={{
validate: async (value) => {
try {
- await loginFormSchema.parseAsync({ username: value });
+ await loginFormSchema.parseAsync({ username: value, password: 'placeholder' });
return true;
- } catch (error: any) {
- return error.message;
+ } catch (err: any) {
+ return err.message;
}
},
}}
@@ -106,7 +106,7 @@ export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = unde
onChangeText={onChange}
onBlur={onBlur}
onSubmitEditing={handleKeyPress}
- returnKeyType="done"
+ returnKeyType="next"
autoCapitalize="none"
autoComplete="off"
/>
@@ -115,10 +115,11 @@ export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = unde
/>
- {errors?.username?.message || (!validated.usernameValid && 'Username not found')}
+ {errors?.username?.message}
- {/* Label Message */}
+
+ {/* Password form */}
{t('login.password')}
@@ -130,10 +131,10 @@ export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = unde
rules={{
validate: async (value) => {
try {
- await loginFormSchema.parseAsync({ password: value });
+ await loginFormSchema.parseAsync({ username: getValues('username'), password: value });
return true;
- } catch (error: any) {
- return error.message;
+ } catch (err: any) {
+ return err.message;
}
},
}}
@@ -168,16 +169,27 @@ export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = unde
{t('login.login_button_loading')}
) : (
-
);
diff --git a/src/app/login/sso.tsx b/src/app/login/sso.tsx
new file mode 100644
index 00000000..0b47d0bf
--- /dev/null
+++ b/src/app/login/sso.tsx
@@ -0,0 +1,293 @@
+import { zodResolver } from '@hookform/resolvers/zod';
+import * as Linking from 'expo-linking';
+import { useRouter } from 'expo-router';
+import { AlertTriangle, ArrowLeft, ShieldCheck } from 'lucide-react-native';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { Controller, useForm } from 'react-hook-form';
+import { useTranslation } from 'react-i18next';
+import { ActivityIndicator } from 'react-native';
+import { KeyboardAvoidingView } from 'react-native-keyboard-controller';
+import * as z from 'zod';
+
+import { FocusAwareStatusBar, View } from '@/components/ui';
+import { Button, ButtonSpinner, ButtonText } from '@/components/ui/button';
+import { FormControl, FormControlError, FormControlErrorIcon, FormControlErrorText, FormControlLabel, FormControlLabelText } from '@/components/ui/form-control';
+import { Input, InputField, InputSlot } from '@/components/ui/input';
+import { Modal, ModalBackdrop, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/ui/modal';
+import { Text } from '@/components/ui/text';
+import colors from '@/constants/colors';
+import { useOidcLogin } from '@/hooks/use-oidc-login';
+import { useSamlLogin } from '@/hooks/use-saml-login';
+import { useAuth } from '@/lib/auth';
+import { logger } from '@/lib/logging';
+import type { DepartmentSsoConfig } from '@/services/sso-discovery';
+import { fetchSsoConfigForUser } from '@/services/sso-discovery';
+
+const ssoFormSchema = z.object({
+ username: z.string({ required_error: 'Username is required' }).min(3, 'Username must be at least 3 characters'),
+ departmentId: z.string().optional(),
+});
+
+type FormType = z.infer;
+
+export default function SsoLogin() {
+ const [ssoConfig, setSsoConfig] = useState(null);
+ const [isLookingUpSso, setIsLookingUpSso] = useState(false);
+ const [isSsoLoading, setIsSsoLoading] = useState(false);
+ const [isErrorModalVisible, setIsErrorModalVisible] = useState(false);
+ const pendingUsernameRef = useRef('');
+
+ const { t } = useTranslation();
+ const router = useRouter();
+ const { ssoLogin, status } = useAuth();
+
+ const oidc = useOidcLogin({
+ authority: ssoConfig?.authority ?? '',
+ clientId: ssoConfig?.clientId ?? '',
+ });
+
+ const { startSamlLogin, handleDeepLink, isSamlCallback } = useSamlLogin();
+
+ const {
+ control,
+ getValues,
+ formState: { errors },
+ } = useForm({ resolver: zodResolver(ssoFormSchema) });
+
+ useEffect(() => {
+ if (status === 'signedIn') {
+ router.push('/(app)');
+ }
+ }, [status, router]);
+
+ useEffect(() => {
+ if (status === 'error') {
+ setIsSsoLoading(false);
+ setIsErrorModalVisible(true);
+ }
+ }, [status]);
+
+ // ── OIDC response handler ─────────────────────────────────────────────────
+ useEffect(() => {
+ if (oidc.response?.type !== 'success') return;
+
+ setIsSsoLoading(true);
+ oidc
+ .exchangeForResgridToken(pendingUsernameRef.current)
+ .then((result) => {
+ if (!result) {
+ setIsSsoLoading(false);
+ setIsErrorModalVisible(true);
+ return;
+ }
+ ssoLogin({
+ provider: 'oidc',
+ externalToken: result.access_token,
+ username: pendingUsernameRef.current,
+ });
+ })
+ .catch(() => {
+ setIsSsoLoading(false);
+ setIsErrorModalVisible(true);
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [oidc.response]);
+
+ // ── Deep-link handler for SAML callbacks ─────────────────────────────────
+ useEffect(() => {
+ const subscription = Linking.addEventListener('url', async ({ url }: { url: string }) => {
+ if (!isSamlCallback(url)) return;
+ setIsSsoLoading(true);
+ const result = await handleDeepLink(url, pendingUsernameRef.current);
+ if (!result) {
+ setIsSsoLoading(false);
+ setIsErrorModalVisible(true);
+ return;
+ }
+ await ssoLogin({
+ provider: 'saml2',
+ externalToken: result.access_token,
+ username: pendingUsernameRef.current,
+ });
+ });
+
+ return () => subscription?.remove();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ // ── SSO lookup (called from username or departmentId blur) ───────────────
+ const triggerSsoLookup = useCallback(async (username: string, departmentIdStr?: string) => {
+ if (username.trim().length < 3) return;
+ pendingUsernameRef.current = username.trim();
+ setIsLookingUpSso(true);
+ setSsoConfig(null);
+
+ const deptId = departmentIdStr ? parseInt(departmentIdStr, 10) : undefined;
+ const resolvedDeptId = deptId && !isNaN(deptId) && deptId > 0 ? deptId : undefined;
+
+ const { config } = await fetchSsoConfigForUser(username.trim(), resolvedDeptId);
+ setIsLookingUpSso(false);
+
+ if (config) {
+ logger.info({
+ message: 'SSO config fetched',
+ context: { ssoEnabled: config.ssoEnabled, providerType: config.providerType, departmentId: resolvedDeptId },
+ });
+ setSsoConfig(config);
+ }
+ }, []);
+
+ const handleUsernameBlur = useCallback((username: string) => triggerSsoLookup(username, getValues('departmentId')), [triggerSsoLookup, getValues]);
+
+ const handleDepartmentIdBlur = useCallback((departmentIdStr: string) => triggerSsoLookup(getValues('username'), departmentIdStr), [triggerSsoLookup, getValues]);
+
+ // ── SSO button press ──────────────────────────────────────────────────────
+ const handleSsoPress = useCallback(async () => {
+ if (!ssoConfig) return;
+ setIsSsoLoading(true);
+
+ if (ssoConfig.providerType === 'oidc') {
+ await oidc.promptAsync();
+ // loading cleared by OIDC response useEffect
+ } else if (ssoConfig.providerType === 'saml2' && ssoConfig.idpSsoUrl) {
+ await startSamlLogin(ssoConfig.idpSsoUrl);
+ setIsSsoLoading(false);
+ } else {
+ setIsSsoLoading(false);
+ }
+ }, [ssoConfig, oidc, startSamlLogin]);
+
+ const showSsoButton = ssoConfig?.ssoEnabled === true;
+
+ return (
+ <>
+
+
+
+ {/* Back button */}
+
+ router.back()} className="self-start">
+
+ {t('common.back')}
+
+
+
+
+
+ {t('login.sso_title')}
+ {t('login.sso_subtitle')}
+
+
+ {/* Username */}
+
+
+ {t('login.username')}
+
+ (
+
+ {
+ onBlur();
+ handleUsernameBlur(value);
+ }}
+ returnKeyType="done"
+ autoCapitalize="none"
+ autoComplete="off"
+ />
+ {isLookingUpSso ? (
+
+
+
+ ) : null}
+
+ )}
+ />
+
+
+ {errors?.username?.message}
+
+
+
+ {/* Department ID (optional) */}
+
+
+ {t('login.sso_department_id')}
+
+ (
+
+ {
+ onBlur();
+ handleDepartmentIdBlur(value ?? '');
+ }}
+ returnKeyType="done"
+ keyboardType="number-pad"
+ autoCapitalize="none"
+ autoComplete="off"
+ />
+
+ )}
+ />
+
+
+ {errors?.departmentId?.message}
+
+
+
+ {/* SSO button — appears after lookup resolves */}
+ {showSsoButton ? (
+
+ {isSsoLoading ? (
+
+
+ {t('login.sso_signing_in')}
+
+ ) : (
+
+
+ {t('login.sso_button')}
+
+ )}
+ {ssoConfig?.departmentName ? {t('login.sso_department', { name: ssoConfig.departmentName })} : null}
+
+ ) : null}
+
+ {/* Hint shown while waiting for lookup */}
+ {!isLookingUpSso && !showSsoButton && pendingUsernameRef.current ? {t('login.sso_not_found')} : null}
+
+
+
+ {/* Error modal */}
+ setIsErrorModalVisible(false)} size="full" {...({} as any)}>
+
+
+
+ {t('login.sso_error_title')}
+
+
+ {t('login.sso_error_message')}
+
+
+ setIsErrorModalVisible(false)}>
+ {t('login.errorModal.confirmButton')}
+
+
+
+
+ >
+ );
+}
diff --git a/src/components/maps/static-map.tsx b/src/components/maps/static-map.tsx
index 38b919d8..c6b48620 100644
--- a/src/components/maps/static-map.tsx
+++ b/src/components/maps/static-map.tsx
@@ -37,15 +37,7 @@ const StaticMap: React.FC = ({ latitude, longitude, address, zoo
return (
-
+
{/* Marker pin for the location */}
diff --git a/src/components/roles/role-assignment-item.tsx b/src/components/roles/role-assignment-item.tsx
index 92026a65..cc2dcef8 100644
--- a/src/components/roles/role-assignment-item.tsx
+++ b/src/components/roles/role-assignment-item.tsx
@@ -96,7 +96,16 @@ export const RoleAssignmentItem: React.FC = ({ role, as
-
+
>
);
};
diff --git a/src/hooks/__tests__/use-oidc-login.test.ts b/src/hooks/__tests__/use-oidc-login.test.ts
new file mode 100644
index 00000000..cab1b132
--- /dev/null
+++ b/src/hooks/__tests__/use-oidc-login.test.ts
@@ -0,0 +1,149 @@
+import { renderHook } from '@testing-library/react-native';
+import axios from 'axios';
+import * as AuthSession from 'expo-auth-session';
+import * as WebBrowser from 'expo-web-browser';
+
+import { useOidcLogin } from '../use-oidc-login';
+
+jest.mock('expo-auth-session');
+jest.mock('expo-web-browser');
+jest.mock('axios');
+jest.mock('@/lib/storage/app', () => ({
+ getBaseApiUrl: jest.fn(() => 'https://api.resgrid.com/api/v4'),
+}));
+jest.mock('@/lib/logging', () => ({
+ logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() },
+}));
+
+const mockedAuthSession = AuthSession as jest.Mocked;
+const mockedAxios = axios as jest.Mocked;
+
+describe('useOidcLogin', () => {
+ const mockPromptAsync = jest.fn();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ (mockedAuthSession.useAutoDiscovery as jest.Mock).mockReturnValue({
+ authorizationEndpoint: 'https://idp.example.com/authorize',
+ tokenEndpoint: 'https://idp.example.com/token',
+ });
+
+ (mockedAuthSession.useAuthRequest as jest.Mock).mockReturnValue([
+ { codeVerifier: 'verifier123' },
+ null,
+ mockPromptAsync,
+ ]);
+
+ (mockedAuthSession.makeRedirectUri as jest.Mock).mockReturnValue(
+ 'resgridunit://auth/callback',
+ );
+ });
+
+ it('renders without error', () => {
+ const { result } = renderHook(() =>
+ useOidcLogin({ authority: 'https://idp.example.com', clientId: 'client123' }),
+ );
+
+ expect(result.current.request).toBeDefined();
+ expect(result.current.promptAsync).toBe(mockPromptAsync);
+ expect(result.current.discovery).toBeDefined();
+ });
+
+ it('returns null from exchangeForResgridToken when response is not success', async () => {
+ (mockedAuthSession.useAuthRequest as jest.Mock).mockReturnValue([
+ { codeVerifier: 'verifier123' },
+ { type: 'cancel' },
+ mockPromptAsync,
+ ]);
+
+ const { result } = renderHook(() =>
+ useOidcLogin({ authority: 'https://idp.example.com', clientId: 'client123' }),
+ );
+
+ const tokenResult = await result.current.exchangeForResgridToken('john.doe');
+ expect(tokenResult).toBeNull();
+ });
+
+ it('returns null when id_token is missing from IdP response', async () => {
+ (mockedAuthSession.useAuthRequest as jest.Mock).mockReturnValue([
+ { codeVerifier: 'verifier123' },
+ { type: 'success', params: { code: 'auth-code-123' } },
+ mockPromptAsync,
+ ]);
+
+ (mockedAuthSession.exchangeCodeAsync as jest.Mock).mockResolvedValueOnce({
+ idToken: undefined,
+ accessToken: 'some-token',
+ });
+
+ const { result } = renderHook(() =>
+ useOidcLogin({ authority: 'https://idp.example.com', clientId: 'client123' }),
+ );
+
+ const tokenResult = await result.current.exchangeForResgridToken('john.doe');
+ expect(tokenResult).toBeNull();
+ });
+
+ it('exchanges id_token for Resgrid token on success', async () => {
+ (mockedAuthSession.useAuthRequest as jest.Mock).mockReturnValue([
+ { codeVerifier: 'verifier123' },
+ { type: 'success', params: { code: 'auth-code-123' } },
+ mockPromptAsync,
+ ]);
+
+ (mockedAuthSession.exchangeCodeAsync as jest.Mock).mockResolvedValueOnce({
+ idToken: 'oidc-id-token',
+ accessToken: 'oidc-access',
+ });
+
+ mockedAxios.post = jest.fn().mockResolvedValueOnce({
+ data: {
+ access_token: 'rg-access',
+ refresh_token: 'rg-refresh',
+ expires_in: 3600,
+ token_type: 'Bearer',
+ },
+ });
+
+ const { result } = renderHook(() =>
+ useOidcLogin({ authority: 'https://idp.example.com', clientId: 'client123' }),
+ );
+
+ const tokenResult = await result.current.exchangeForResgridToken('john.doe');
+
+ expect(tokenResult).toEqual({
+ access_token: 'rg-access',
+ refresh_token: 'rg-refresh',
+ expires_in: 3600,
+ token_type: 'Bearer',
+ });
+
+ expect(mockedAxios.post).toHaveBeenCalledWith(
+ 'https://api.resgrid.com/api/v4/connect/external-token',
+ expect.stringContaining('external_token=oidc-id-token'),
+ expect.objectContaining({ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }),
+ );
+ });
+
+ it('returns null when Resgrid API call fails', async () => {
+ (mockedAuthSession.useAuthRequest as jest.Mock).mockReturnValue([
+ { codeVerifier: 'verifier123' },
+ { type: 'success', params: { code: 'auth-code-123' } },
+ mockPromptAsync,
+ ]);
+
+ (mockedAuthSession.exchangeCodeAsync as jest.Mock).mockResolvedValueOnce({
+ idToken: 'oidc-id-token',
+ });
+
+ mockedAxios.post = jest.fn().mockRejectedValueOnce(new Error('API Error'));
+
+ const { result } = renderHook(() =>
+ useOidcLogin({ authority: 'https://idp.example.com', clientId: 'client123' }),
+ );
+
+ const tokenResult = await result.current.exchangeForResgridToken('john.doe');
+ expect(tokenResult).toBeNull();
+ });
+});
diff --git a/src/hooks/__tests__/use-saml-login.test.ts b/src/hooks/__tests__/use-saml-login.test.ts
new file mode 100644
index 00000000..c7521d60
--- /dev/null
+++ b/src/hooks/__tests__/use-saml-login.test.ts
@@ -0,0 +1,140 @@
+import { renderHook } from '@testing-library/react-native';
+import axios from 'axios';
+import * as Linking from 'expo-linking';
+import * as WebBrowser from 'expo-web-browser';
+
+import { useSamlLogin } from '../use-saml-login';
+
+jest.mock('expo-web-browser');
+jest.mock('expo-linking', () => ({
+ parse: jest.fn(),
+ addEventListener: jest.fn(() => ({ remove: jest.fn() })),
+}));
+jest.mock('axios');
+jest.mock('@/lib/storage/app', () => ({
+ getBaseApiUrl: jest.fn(() => 'https://api.resgrid.com/api/v4'),
+}));
+jest.mock('@/lib/logging', () => ({
+ logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() },
+}));
+
+const mockedWebBrowser = WebBrowser as jest.Mocked;
+const mockedLinking = Linking as jest.Mocked;
+const mockedAxios = axios as jest.Mocked;
+
+describe('useSamlLogin', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders without error', () => {
+ const { result } = renderHook(() => useSamlLogin());
+ expect(result.current.startSamlLogin).toBeDefined();
+ expect(result.current.handleDeepLink).toBeDefined();
+ expect(result.current.isSamlCallback).toBeDefined();
+ });
+
+ it('startSamlLogin opens browser with the given URL', async () => {
+ (mockedWebBrowser.openBrowserAsync as jest.Mock).mockResolvedValueOnce({ type: 'dismiss' });
+
+ const { result } = renderHook(() => useSamlLogin());
+ await result.current.startSamlLogin('https://idp.example.com/saml/sso');
+
+ expect(mockedWebBrowser.openBrowserAsync).toHaveBeenCalledWith(
+ 'https://idp.example.com/saml/sso',
+ );
+ });
+
+ it('handleDeepLink returns null when saml_response param is missing', async () => {
+ (mockedLinking.parse as jest.Mock).mockReturnValueOnce({
+ scheme: 'resgridunit',
+ path: 'auth/callback',
+ queryParams: {},
+ });
+
+ const { result } = renderHook(() => useSamlLogin());
+ const tokenResult = await result.current.handleDeepLink(
+ 'resgridunit://auth/callback',
+ 'john.doe',
+ );
+
+ expect(tokenResult).toBeNull();
+ });
+
+ it('handleDeepLink exchanges SAMLResponse for Resgrid token on success', async () => {
+ (mockedLinking.parse as jest.Mock).mockReturnValueOnce({
+ scheme: 'resgridunit',
+ path: 'auth/callback',
+ queryParams: { saml_response: 'base64SamlResponse' },
+ });
+
+ mockedAxios.post = jest.fn().mockResolvedValueOnce({
+ data: {
+ access_token: 'rg-access',
+ refresh_token: 'rg-refresh',
+ expires_in: 3600,
+ token_type: 'Bearer',
+ },
+ });
+
+ const { result } = renderHook(() => useSamlLogin());
+ const tokenResult = await result.current.handleDeepLink(
+ 'resgridunit://auth/callback?saml_response=base64SamlResponse',
+ 'john.doe',
+ );
+
+ expect(tokenResult).toEqual({
+ access_token: 'rg-access',
+ refresh_token: 'rg-refresh',
+ expires_in: 3600,
+ token_type: 'Bearer',
+ });
+
+ expect(mockedAxios.post).toHaveBeenCalledWith(
+ 'https://api.resgrid.com/api/v4/connect/external-token',
+ expect.stringContaining('provider=saml2'),
+ expect.any(Object),
+ );
+ });
+
+ it('handleDeepLink returns null when Resgrid API call fails', async () => {
+ (mockedLinking.parse as jest.Mock).mockReturnValueOnce({
+ scheme: 'resgridunit',
+ path: 'auth/callback',
+ queryParams: { saml_response: 'base64SamlResponse' },
+ });
+
+ mockedAxios.post = jest.fn().mockRejectedValueOnce(new Error('API Error'));
+
+ const { result } = renderHook(() => useSamlLogin());
+ const tokenResult = await result.current.handleDeepLink(
+ 'resgridunit://auth/callback?saml_response=base64SamlResponse',
+ 'john.doe',
+ );
+
+ expect(tokenResult).toBeNull();
+ });
+
+ describe('isSamlCallback', () => {
+ it('returns true for SAML callback URLs', () => {
+ const { result } = renderHook(() => useSamlLogin());
+ expect(
+ result.current.isSamlCallback(
+ 'resgridunit://auth/callback?saml_response=abc123',
+ ),
+ ).toBe(true);
+ });
+
+ it('returns false for OIDC callback URLs without saml_response', () => {
+ const { result } = renderHook(() => useSamlLogin());
+ expect(
+ result.current.isSamlCallback('resgridunit://auth/callback?code=abc&state=xyz'),
+ ).toBe(false);
+ });
+
+ it('returns false for unrelated URLs', () => {
+ const { result } = renderHook(() => useSamlLogin());
+ expect(result.current.isSamlCallback('https://example.com')).toBe(false);
+ });
+ });
+});
diff --git a/src/hooks/use-oidc-login.ts b/src/hooks/use-oidc-login.ts
new file mode 100644
index 00000000..156e6fc8
--- /dev/null
+++ b/src/hooks/use-oidc-login.ts
@@ -0,0 +1,105 @@
+import axios from 'axios';
+import * as AuthSession from 'expo-auth-session';
+import * as WebBrowser from 'expo-web-browser';
+
+import { logger } from '@/lib/logging';
+import { getBaseApiUrl } from '@/lib/storage/app';
+
+// Required for iOS / Android to close the browser after redirect
+WebBrowser.maybeCompleteAuthSession();
+
+export interface UseOidcLoginOptions {
+ authority: string;
+ clientId: string;
+}
+
+export interface OidcExchangeResult {
+ access_token: string;
+ refresh_token: string;
+ id_token?: string;
+ expires_in: number;
+ token_type: string;
+ expiration_date?: string;
+}
+
+/**
+ * Hook that drives the OIDC Authorization-Code + PKCE flow.
+ *
+ * Usage:
+ * const { request, promptAsync, exchangeForResgridToken } = useOidcLogin({ authority, clientId });
+ * // 1. call promptAsync() on button press
+ * // 2. watch response inside a useEffect and call exchangeForResgridToken(username) when type === 'success'
+ */
+export function useOidcLogin({ authority, clientId }: UseOidcLoginOptions) {
+ const redirectUri = AuthSession.makeRedirectUri({
+ scheme: 'ResgridUnit',
+ path: 'auth/callback',
+ });
+
+ const discovery = AuthSession.useAutoDiscovery(authority);
+
+ const [request, response, promptAsync] = AuthSession.useAuthRequest(
+ {
+ clientId,
+ redirectUri,
+ scopes: ['openid', 'email', 'profile', 'offline_access'],
+ usePKCE: true,
+ responseType: AuthSession.ResponseType.Code,
+ },
+ discovery
+ );
+
+ /**
+ * Exchange the OIDC authorization code for a Resgrid access token.
+ * Should be called after `response?.type === 'success'`.
+ */
+ async function exchangeForResgridToken(username: string): Promise {
+ if (response?.type !== 'success' || !request?.codeVerifier || !discovery) {
+ logger.warn({
+ message: 'OIDC exchange called in invalid state',
+ context: { responseType: response?.type, hasCodeVerifier: !!request?.codeVerifier, hasDiscovery: !!discovery },
+ });
+ return null;
+ }
+
+ try {
+ // Step 1: exchange auth code for id_token at the IdP
+ const tokenResponse = await AuthSession.exchangeCodeAsync(
+ {
+ clientId,
+ redirectUri,
+ code: response.params.code,
+ extraParams: { code_verifier: request.codeVerifier },
+ },
+ discovery
+ );
+
+ const idToken = tokenResponse.idToken;
+ if (!idToken) {
+ logger.error({ message: 'OIDC exchange: no id_token in IdP response' });
+ return null;
+ }
+
+ // Step 2: exchange id_token for Resgrid access/refresh tokens
+ const params = new URLSearchParams({
+ provider: 'oidc',
+ external_token: idToken,
+ username,
+ scope: 'openid email profile offline_access mobile',
+ });
+
+ const resgridResponse = await axios.post(`${getBaseApiUrl()}/connect/external-token`, params.toString(), { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } });
+
+ logger.info({ message: 'OIDC Resgrid token exchange successful' });
+ return resgridResponse.data;
+ } catch (error) {
+ logger.error({
+ message: 'OIDC token exchange failed',
+ context: { error: error instanceof Error ? error.message : String(error) },
+ });
+ return null;
+ }
+ }
+
+ return { request, response, promptAsync, exchangeForResgridToken, discovery };
+}
diff --git a/src/hooks/use-saml-login.ts b/src/hooks/use-saml-login.ts
new file mode 100644
index 00000000..ea1cc9c2
--- /dev/null
+++ b/src/hooks/use-saml-login.ts
@@ -0,0 +1,90 @@
+import axios from 'axios';
+import * as Linking from 'expo-linking';
+import * as WebBrowser from 'expo-web-browser';
+
+import { logger } from '@/lib/logging';
+import { getBaseApiUrl } from '@/lib/storage/app';
+
+export interface SamlExchangeResult {
+ access_token: string;
+ refresh_token: string;
+ id_token?: string;
+ expires_in: number;
+ token_type: string;
+ expiration_date?: string;
+}
+
+/**
+ * SAML 2.0 SSO flow:
+ * 1. Open the IdP-initiated SSO URL in the system browser.
+ * 2. The IdP POSTs a SAMLResponse to the SP ACS endpoint.
+ * 3. The backend ACS endpoint redirects to ResgridUnit://auth/callback?saml_response=.
+ * 4. The app intercepts the deep link and calls handleDeepLink() to exchange
+ * the SAMLResponse for Resgrid access/refresh tokens.
+ *
+ * NOTE: The backend must expose a
+ * GET/POST /api/v4/connect/saml-mobile-callback endpoint that accepts the
+ * SAMLResponse and issues a 302 redirect to the app scheme (see plan Step 8).
+ */
+export function useSamlLogin() {
+ /**
+ * Open the IdP SSO URL in the system browser.
+ * The browser will handle the full SAML redirect chain.
+ */
+ async function startSamlLogin(idpSsoUrl: string): Promise {
+ try {
+ await WebBrowser.openBrowserAsync(idpSsoUrl);
+ } catch (error) {
+ logger.error({
+ message: 'Failed to open SAML SSO browser',
+ context: { error: error instanceof Error ? error.message : String(error) },
+ });
+ }
+ }
+
+ /**
+ * Handle the deep-link callback that carries the base64-encoded SAMLResponse.
+ * Returns the Resgrid token pair on success, or null on failure.
+ *
+ * @param url The full deep-link URL (e.g. ResgridUnit://auth/callback?saml_response=...)
+ * @param username The username entered before the SAML flow started (used by the backend)
+ */
+ async function handleDeepLink(url: string, username: string): Promise {
+ try {
+ const parsed = Linking.parse(url);
+ const samlResponse = parsed.queryParams?.saml_response as string | undefined;
+
+ if (!samlResponse) {
+ logger.warn({ message: 'SAML deep-link missing saml_response param', context: { url } });
+ return null;
+ }
+
+ const params = new URLSearchParams({
+ provider: 'saml2',
+ external_token: samlResponse,
+ username,
+ scope: 'openid email profile offline_access mobile',
+ });
+
+ const resgridResponse = await axios.post(`${getBaseApiUrl()}/connect/external-token`, params.toString(), { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } });
+
+ logger.info({ message: 'SAML Resgrid token exchange successful' });
+ return resgridResponse.data;
+ } catch (error) {
+ logger.error({
+ message: 'SAML token exchange failed',
+ context: { error: error instanceof Error ? error.message : String(error) },
+ });
+ return null;
+ }
+ }
+
+ /**
+ * Check whether a deep-link URL is a SAML callback.
+ */
+ function isSamlCallback(url: string): boolean {
+ return url.includes('auth/callback') && url.includes('saml_response');
+ }
+
+ return { startSamlLogin, handleDeepLink, isSamlCallback };
+}
diff --git a/src/lib/auth/__tests__/sso-api.test.ts b/src/lib/auth/__tests__/sso-api.test.ts
new file mode 100644
index 00000000..2ac5739a
--- /dev/null
+++ b/src/lib/auth/__tests__/sso-api.test.ts
@@ -0,0 +1,91 @@
+import { ssoExternalTokenRequest } from '../api';
+
+// `var` is hoisted above jest.mock so the factory lambda can close over it.
+// eslint-disable-next-line no-var
+var mockPost: jest.Mock = jest.fn();
+
+jest.mock('axios', () => ({
+ // Wrap in an arrow so that the binding is resolved at call-time, not factory-time.
+ create: jest.fn(() => ({ post: (...args: unknown[]) => mockPost(...args) })),
+ isAxiosError: jest.fn(),
+}));
+
+jest.mock('@env', () => ({
+ Env: { IS_MOBILE_APP: 'true' },
+}));
+jest.mock('@/lib/storage/app', () => ({
+ getBaseApiUrl: jest.fn(() => 'https://api.resgrid.com/api/v4'),
+}));
+jest.mock('@/lib/logging', () => ({
+ logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() },
+}));
+
+describe('ssoExternalTokenRequest', () => {
+ beforeEach(() => {
+ mockPost.mockReset();
+ });
+
+ it('returns successful response on valid OIDC token', async () => {
+ mockPost.mockResolvedValueOnce({
+ status: 200,
+ data: {
+ access_token: 'rg-access',
+ refresh_token: 'rg-refresh',
+ id_token: 'rg-id',
+ expires_in: 3600,
+ token_type: 'Bearer',
+ },
+ });
+
+ const result = await ssoExternalTokenRequest({
+ provider: 'oidc',
+ externalToken: 'idp-id-token',
+ username: 'john.doe',
+ });
+
+ expect(result.successful).toBe(true);
+ expect(result.authResponse?.access_token).toBe('rg-access');
+ expect(mockPost).toHaveBeenCalledWith(
+ '/connect/external-token',
+ expect.stringContaining('provider=oidc'),
+ );
+ });
+
+ it('returns successful response on valid SAML token', async () => {
+ mockPost.mockResolvedValueOnce({
+ status: 200,
+ data: {
+ access_token: 'rg-access-saml',
+ refresh_token: 'rg-refresh-saml',
+ id_token: 'rg-id-saml',
+ expires_in: 3600,
+ token_type: 'Bearer',
+ },
+ });
+
+ const result = await ssoExternalTokenRequest({
+ provider: 'saml2',
+ externalToken: 'base64SamlResponse',
+ username: 'john.doe',
+ });
+
+ expect(result.successful).toBe(true);
+ expect(mockPost).toHaveBeenCalledWith(
+ '/connect/external-token',
+ expect.stringContaining('provider=saml2'),
+ );
+ });
+
+ it('throws when the API call fails', async () => {
+ mockPost.mockRejectedValueOnce(new Error('Unauthorized'));
+
+ await expect(
+ ssoExternalTokenRequest({
+ provider: 'oidc',
+ externalToken: 'bad-token',
+ username: 'unknown',
+ }),
+ ).rejects.toThrow('Unauthorized');
+ });
+});
+
diff --git a/src/lib/auth/api.tsx b/src/lib/auth/api.tsx
index 10d810ae..4cd0a1cf 100644
--- a/src/lib/auth/api.tsx
+++ b/src/lib/auth/api.tsx
@@ -5,7 +5,7 @@ import queryString from 'query-string';
import { logger } from '@/lib/logging';
import { getBaseApiUrl } from '../storage/app';
-import type { AuthResponse, LoginCredentials, LoginResponse } from './types';
+import type { AuthResponse, LoginCredentials, LoginResponse, SsoLoginCredentials } from './types';
const authApi = axios.create({
baseURL: getBaseApiUrl(),
@@ -80,3 +80,46 @@ export const refreshTokenRequest = async (refreshToken: string): Promise => {
+ try {
+ const data = queryString.stringify({
+ provider: credentials.provider,
+ external_token: credentials.externalToken,
+ username: credentials.username,
+ 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, username: credentials.username },
+ });
+
+ return {
+ successful: true,
+ message: 'SSO login successful',
+ authResponse: response.data,
+ };
+ }
+
+ logger.error({
+ message: 'SSO external token exchange failed',
+ context: { status: response.status, username: credentials.username },
+ });
+
+ return { successful: false, message: 'SSO login failed', authResponse: null };
+ } catch (error) {
+ logger.error({
+ message: 'SSO external token exchange error',
+ context: { error, username: credentials.username },
+ });
+ throw error;
+ }
+};
diff --git a/src/lib/auth/index.tsx b/src/lib/auth/index.tsx
index 9c98d087..305bb219 100644
--- a/src/lib/auth/index.tsx
+++ b/src/lib/auth/index.tsx
@@ -10,12 +10,13 @@ export * from './types';
// Utility hooks and selectors
export const useAuth = () => {
- const { accessToken, status, error, login, logout, hydrate } = useAuthStore(
+ const { accessToken, status, error, login, ssoLogin, logout, hydrate } = useAuthStore(
useShallow((state) => ({
accessToken: state.accessToken,
status: state.status,
error: state.error,
login: state.login,
+ ssoLogin: state.ssoLogin,
logout: state.logout,
hydrate: state.hydrate,
}))
@@ -25,6 +26,7 @@ export const useAuth = () => {
isLoading: status === 'loading',
error,
login,
+ ssoLogin,
logout,
status,
hydrate,
diff --git a/src/lib/auth/types.tsx b/src/lib/auth/types.tsx
index 42e66d14..2b3f2d28 100644
--- a/src/lib/auth/types.tsx
+++ b/src/lib/auth/types.tsx
@@ -3,6 +3,13 @@ export interface AuthTokens {
refreshToken: string;
}
+export interface SsoLoginCredentials {
+ /** The external token: id_token (OIDC) or base64 SAMLResponse (SAML 2.0) */
+ externalToken: string;
+ provider: 'oidc' | 'saml2';
+ username: string;
+}
+
export interface LoginCredentials {
username: string;
password: string;
@@ -48,6 +55,7 @@ export interface AuthState {
userId: string | null;
refreshTimeoutId: ReturnType | null;
login: (credentials: LoginCredentials) => Promise;
+ ssoLogin: (credentials: SsoLoginCredentials) => Promise;
logout: () => Promise;
refreshAccessToken: () => Promise;
hydrate: () => void;
diff --git a/src/services/__tests__/sso-discovery.test.ts b/src/services/__tests__/sso-discovery.test.ts
new file mode 100644
index 00000000..5498d062
--- /dev/null
+++ b/src/services/__tests__/sso-discovery.test.ts
@@ -0,0 +1,96 @@
+import axios from 'axios';
+
+import { fetchSsoConfigForUser } from '../sso-discovery';
+
+jest.mock('axios');
+jest.mock('@/lib/storage/app', () => ({
+ getBaseApiUrl: jest.fn(() => 'https://api.resgrid.com/api/v4'),
+}));
+
+const mockedAxios = axios as jest.Mocked;
+
+describe('fetchSsoConfigForUser', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (mockedAxios.isAxiosError as unknown as jest.Mock).mockReturnValue(false);
+ });
+
+ it('returns config and userExists=true on success', async () => {
+ const config = {
+ ssoEnabled: true,
+ providerType: 'oidc',
+ authority: 'https://idp.example.com',
+ clientId: 'client123',
+ metadataUrl: null,
+ entityId: null,
+ idpSsoUrl: null,
+ allowLocalLogin: true,
+ requireSso: false,
+ requireMfa: false,
+ oidcRedirectUri: 'resgridunit://auth/callback',
+ oidcScopes: 'openid email profile offline_access',
+ departmentId: 42,
+ departmentName: 'Test Department',
+ };
+
+ mockedAxios.get = jest.fn().mockResolvedValueOnce({ data: { Data: config } });
+
+ const result = await fetchSsoConfigForUser('john.doe');
+
+ expect(result).toEqual({ config, userExists: true });
+ expect(mockedAxios.get).toHaveBeenCalledWith(
+ 'https://api.resgrid.com/api/v4/connect/sso-config-for-user',
+ { params: { username: 'john.doe' } },
+ );
+ });
+
+ it('passes departmentId when provided', async () => {
+ mockedAxios.get = jest.fn().mockResolvedValueOnce({ data: { Data: { ssoEnabled: false } } });
+
+ await fetchSsoConfigForUser('john.doe', 99);
+
+ expect(mockedAxios.get).toHaveBeenCalledWith(
+ 'https://api.resgrid.com/api/v4/connect/sso-config-for-user',
+ { params: { username: 'john.doe', departmentId: 99 } },
+ );
+ });
+
+ it('returns { config: null, userExists: false } when Data is missing', async () => {
+ mockedAxios.get = jest.fn().mockResolvedValueOnce({ data: {} });
+
+ const result = await fetchSsoConfigForUser('unknown');
+
+ expect(result).toEqual({ config: null, userExists: false });
+ });
+
+ it('returns { config: null, userExists: false } on 404 (user not a member)', async () => {
+ const axiosError = { response: { status: 404 } };
+ (mockedAxios.isAxiosError as unknown as jest.Mock).mockReturnValueOnce(true);
+ mockedAxios.get = jest.fn().mockRejectedValueOnce(axiosError);
+
+ const result = await fetchSsoConfigForUser('john.doe', 5);
+
+ expect(result).toEqual({ config: null, userExists: false });
+ });
+
+ it('returns { config: null, userExists: false } on network error', async () => {
+ mockedAxios.get = jest.fn().mockRejectedValueOnce(new Error('Network Error'));
+
+ const result = await fetchSsoConfigForUser('john.doe');
+
+ expect(result).toEqual({ config: null, userExists: false });
+ });
+
+ it('returns { config: null, userExists: false } when ssoEnabled is false', async () => {
+ mockedAxios.get = jest.fn().mockResolvedValueOnce({
+ data: { Data: { ssoEnabled: false, allowLocalLogin: true } },
+ });
+
+ const result = await fetchSsoConfigForUser('localuser');
+
+ expect(result).toEqual({
+ config: { ssoEnabled: false, allowLocalLogin: true },
+ userExists: true,
+ });
+ });
+});
diff --git a/src/services/sso-discovery.ts b/src/services/sso-discovery.ts
new file mode 100644
index 00000000..535f78bd
--- /dev/null
+++ b/src/services/sso-discovery.ts
@@ -0,0 +1,61 @@
+import axios from 'axios';
+
+import { getBaseApiUrl } from '@/lib/storage/app';
+
+export interface DepartmentSsoConfig {
+ ssoEnabled: boolean;
+ providerType: 'oidc' | 'saml2' | null;
+ authority: string | null;
+ clientId: string | null;
+ metadataUrl: string | null;
+ entityId: string | null;
+ idpSsoUrl: string | null;
+ allowLocalLogin: boolean;
+ requireSso: boolean;
+ requireMfa: boolean;
+ oidcRedirectUri: string;
+ oidcScopes: string;
+ departmentId: number | null;
+ departmentName: string | null;
+}
+
+export interface SsoConfigForUserResult {
+ config: DepartmentSsoConfig | null;
+ userExists: boolean;
+}
+
+/**
+ * Fetch the SSO configuration for a given username (and optional departmentId).
+ * Uses the updated /api/v4/Connect/sso-config-for-user endpoint which does
+ * username-first discovery: it resolves the user's active/default department
+ * automatically, or scopes to a specific department when departmentId is provided.
+ *
+ * Returns { config: null, userExists: false } when the account does not exist
+ * (the backend returns allowLocalLogin:true / ssoEnabled:false to avoid
+ * account enumeration, so we treat a null / empty response as "not found").
+ */
+export async function fetchSsoConfigForUser(username: string, departmentId?: number): Promise {
+ try {
+ const params: Record = { username };
+ if (departmentId !== undefined) {
+ params.departmentId = departmentId;
+ }
+
+ const response = await axios.get(`${getBaseApiUrl()}/connect/sso-config-for-user`, {
+ params,
+ });
+
+ const data = response.data?.Data ?? null;
+ if (!data) {
+ return { config: null, userExists: false };
+ }
+
+ return { config: data as DepartmentSsoConfig, userExists: true };
+ } catch (error: unknown) {
+ if (axios.isAxiosError(error) && error.response?.status === 404) {
+ // User not a member of the specified department
+ return { config: null, userExists: false };
+ }
+ return { config: null, userExists: false };
+ }
+}
diff --git a/src/stores/app/livekit-store.ts b/src/stores/app/livekit-store.ts
index 45110df1..0fda9bb2 100644
--- a/src/stores/app/livekit-store.ts
+++ b/src/stores/app/livekit-store.ts
@@ -454,11 +454,7 @@ export const useLiveKitStore = create((set, get) => ({
context: { roomName: roomInfo.Name, timeoutMs: CONNECT_OVERALL_TIMEOUT_MS },
});
set({ isConnecting: false });
- Alert.alert(
- 'Voice Connection Timeout',
- `The connection to "${roomInfo.Name}" took too long. Please try again.`,
- [{ text: 'OK' }]
- );
+ Alert.alert('Voice Connection Timeout', `The connection to "${roomInfo.Name}" took too long. Please try again.`, [{ text: 'OK' }]);
}
}, CONNECT_OVERALL_TIMEOUT_MS);
@@ -493,11 +489,7 @@ export const useLiveKitStore = create((set, get) => ({
context: { roomName: roomInfo.Name },
});
- const permissionsGranted = await withTimeout(
- get().requestPermissions(),
- 10_000,
- 'requestPermissions'
- );
+ const permissionsGranted = await withTimeout(get().requestPermissions(), 10_000, 'requestPermissions');
logger.debug({
message: 'connectToRoom: microphone permission result',
@@ -530,11 +522,7 @@ export const useLiveKitStore = create((set, get) => ({
if (Platform.OS !== 'web') {
try {
logger.debug({ message: 'connectToRoom: starting audio session' });
- await withTimeout(
- AudioSession.startAudioSession(),
- 10_000,
- 'AudioSession.startAudioSession'
- );
+ await withTimeout(AudioSession.startAudioSession(), 10_000, 'AudioSession.startAudioSession');
logger.info({
message: 'Audio session started successfully',
});
@@ -587,11 +575,7 @@ export const useLiveKitStore = create((set, get) => ({
hasToken: !!token,
},
});
- await withTimeout(
- room.connect(voipServerWebsocketSslAddress, token),
- 15_000,
- 'room.connect'
- );
+ await withTimeout(room.connect(voipServerWebsocketSslAddress, token), 15_000, 'room.connect');
logger.info({
message: 'LiveKit room connected successfully',
context: { roomName: roomInfo.Name },
diff --git a/src/stores/auth/store.tsx b/src/stores/auth/store.tsx
index 48074b31..7aac887b 100644
--- a/src/stores/auth/store.tsx
+++ b/src/stores/auth/store.tsx
@@ -4,8 +4,8 @@ import { createJSONStorage, persist } from 'zustand/middleware';
import { logger } from '@/lib/logging';
-import { loginRequest, refreshTokenRequest } from '../../lib/auth/api';
-import type { AuthResponse, AuthState, LoginCredentials } from '../../lib/auth/types';
+import { loginRequest, refreshTokenRequest, ssoExternalTokenRequest } from '../../lib/auth/api';
+import type { AuthResponse, AuthState, LoginCredentials, SsoLoginCredentials } from '../../lib/auth/types';
import { type ProfileModel } from '../../lib/auth/types';
import { getAuth } from '../../lib/auth/utils';
import { setItem, zustandStorage } from '../../lib/storage';
@@ -86,6 +86,52 @@ const useAuthStore = create()(
}
},
+ ssoLogin: async (credentials: SsoLoginCredentials) => {
+ try {
+ set({ status: 'loading' });
+ const response = await ssoExternalTokenRequest(credentials);
+
+ if (response.successful && response.authResponse) {
+ const payload = sanitizeJson(base64.decode(response.authResponse.id_token!.split('.')[1]));
+
+ setItem('authResponse', response.authResponse);
+ const expiresOn = new Date(Date.now() + response.authResponse.expires_in * 1000).getTime().toString();
+
+ const profileData = JSON.parse(payload) as ProfileModel;
+
+ set({
+ accessToken: response.authResponse.access_token,
+ refreshToken: response.authResponse.refresh_token,
+ refreshTokenExpiresOn: expiresOn,
+ status: 'signedIn',
+ error: null,
+ profile: profileData,
+ userId: profileData.sub,
+ });
+
+ const refreshDelayMs = Math.max((response.authResponse.expires_in - 60) * 1000, 60000);
+ logger.info({
+ message: 'SSO login successful, scheduling token refresh',
+ context: { refreshDelayMs, provider: credentials.provider },
+ });
+
+ const existingTimeoutId = get().refreshTimeoutId;
+ if (existingTimeoutId !== null) {
+ clearTimeout(existingTimeoutId);
+ }
+ const timeoutId = setTimeout(() => get().refreshAccessToken(), refreshDelayMs);
+ set({ refreshTimeoutId: timeoutId });
+ } else {
+ set({ status: 'error', error: response.message });
+ }
+ } catch (error) {
+ set({
+ status: 'error',
+ error: error instanceof Error ? error.message : 'SSO login failed',
+ });
+ }
+ },
+
logout: async () => {
// Clear any pending refresh timer to prevent stacked timeouts
const existingTimeoutId = get().refreshTimeoutId;
diff --git a/src/stores/roles/store.ts b/src/stores/roles/store.ts
index c7fcbc99..e912c397 100644
--- a/src/stores/roles/store.ts
+++ b/src/stores/roles/store.ts
@@ -39,10 +39,7 @@ export const useRolesStore = create((set) => ({
}
set({ isLoading: true, error: null });
try {
- const [response, personnelResponse] = await Promise.all([
- getAllUnitRolesAndAssignmentsForDepartment(),
- getAllPersonnelInfos(''),
- ]);
+ const [response, personnelResponse] = await Promise.all([getAllUnitRolesAndAssignmentsForDepartment(), getAllPersonnelInfos('')]);
set({
roles: response.Data,
@@ -101,11 +98,7 @@ export const useRolesStore = create((set) => ({
fetchAllForUnit: async (unitId: string) => {
set({ isLoading: true, error: null });
try {
- const [rolesResponse, unitRoles, personnelResponse] = await Promise.all([
- getAllUnitRolesAndAssignmentsForDepartment(),
- getRoleAssignmentsForUnit(unitId),
- getAllPersonnelInfos(''),
- ]);
+ const [rolesResponse, unitRoles, personnelResponse] = await Promise.all([getAllUnitRolesAndAssignmentsForDepartment(), getRoleAssignmentsForUnit(unitId), getAllPersonnelInfos('')]);
set({
roles: rolesResponse.Data,
diff --git a/src/translations/ar.json b/src/translations/ar.json
index a6d31e30..66b9a45f 100644
--- a/src/translations/ar.json
+++ b/src/translations/ar.json
@@ -472,11 +472,24 @@
"login_button_error": "خطأ في تسجيل الدخول",
"login_button_loading": "تسجيل الدخول...",
"login_button_success": "تم تسجيل الدخول بنجاح",
+ "or_sign_in_with_password": "أو تسجيل الدخول بكلمة المرور",
"password": "كلمة المرور",
"password_incorrect": "كانت كلمة المرور غير صحيحة",
"password_placeholder": "أدخل كلمة المرور الخاصة بك",
+ "sso_button": "تسجيل الدخول عبر SSO",
+ "sso_department": "SSO مقدم من {{name}}",
+ "sso_department_id": "معرف القسم (اختياري)",
+ "sso_department_id_placeholder": "أدخل معرف القسم الخاص بك",
+ "sso_error_message": "تعذر إتمام تسجيل الدخول عبر SSO. يرجى المحاولة مرة أخرى.",
+ "sso_error_title": "خطأ في SSO",
+ "sso_not_found": "لم يتم العثور على تكوين SSO لاسم المستخدم هذا",
+ "sso_signing_in": "جارٍ تسجيل الدخول عبر SSO...",
+ "sso_subtitle": "أدخل اسم المستخدم الخاص بك للبحث عن تكوين SSO",
+ "sso_title": "تسجيل الدخول عبر SSO",
+ "subtitle": "أدخل بيانات اعتمادك للوصول إلى Resgrid Unit",
"title": "تسجيل الدخول",
"username": "اسم المستخدم",
+ "username_not_found": "اسم المستخدم غير موجود",
"username_placeholder": "أدخل اسم المستخدم الخاص بك"
},
"map": {
diff --git a/src/translations/en.json b/src/translations/en.json
index 01a32a73..4067e456 100644
--- a/src/translations/en.json
+++ b/src/translations/en.json
@@ -467,16 +467,29 @@
"title": "Login Failed"
},
"login": "Login",
- "login_button": "Login",
+ "login_button": "Sign In",
"login_button_description": "Login to your account to continue",
"login_button_error": "Error logging in",
- "login_button_loading": "Logging in...",
+ "login_button_loading": "Signing in...",
"login_button_success": "Logged in successfully",
+ "or_sign_in_with_password": "or sign in with password",
"password": "Password",
"password_incorrect": "Password was incorrect",
"password_placeholder": "Enter your password",
- "title": "Login",
+ "sso_button": "Sign in with SSO",
+ "sso_department": "SSO provided by {{name}}",
+ "sso_department_id": "Department ID (optional)",
+ "sso_department_id_placeholder": "Enter your department ID",
+ "sso_error_message": "Could not complete SSO sign-in. Please try again.",
+ "sso_error_title": "SSO Error",
+ "sso_not_found": "No SSO configuration found for this username",
+ "sso_signing_in": "Signing in with SSO...",
+ "sso_subtitle": "Enter your username to look up your SSO configuration",
+ "sso_title": "Sign In with SSO",
+ "subtitle": "Enter your credentials to access Resgrid Unit",
+ "title": "Sign In",
"username": "Username",
+ "username_not_found": "Username not found",
"username_placeholder": "Enter your username"
},
"map": {
diff --git a/src/translations/es.json b/src/translations/es.json
index 0f270231..5bfbc847 100644
--- a/src/translations/es.json
+++ b/src/translations/es.json
@@ -472,11 +472,24 @@
"login_button_error": "Error al iniciar sesión",
"login_button_loading": "Iniciando sesión...",
"login_button_success": "Sesión iniciada con éxito",
+ "or_sign_in_with_password": "o inicia sesión con contraseña",
"password": "Contraseña",
"password_incorrect": "La contraseña era incorrecta",
"password_placeholder": "Introduce tu contraseña",
+ "sso_button": "Iniciar sesión con SSO",
+ "sso_department": "SSO proporcionado por {{name}}",
+ "sso_department_id": "ID de departamento (opcional)",
+ "sso_department_id_placeholder": "Ingresa tu ID de departamento",
+ "sso_error_message": "No se pudo completar el inicio de sesión con SSO. Inténtalo de nuevo.",
+ "sso_error_title": "Error de SSO",
+ "sso_not_found": "No se encontró configuración de SSO para este nombre de usuario",
+ "sso_signing_in": "Iniciando sesión con SSO...",
+ "sso_subtitle": "Ingresa tu nombre de usuario para buscar tu configuración de SSO",
+ "sso_title": "Iniciar sesión con SSO",
+ "subtitle": "Introduce tus credenciales para acceder a Resgrid Unit",
"title": "Iniciar sesión",
"username": "Nombre de usuario",
+ "username_not_found": "Usuario no encontrado",
"username_placeholder": "Introduce tu nombre de usuario"
},
"map": {
diff --git a/yarn.lock b/yarn.lock
index fbcf9540..3e75924a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6489,7 +6489,7 @@ balanced-match@^1.0.0:
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
-base64-js@1.5.1, base64-js@^1.2.3, base64-js@^1.3.1, base64-js@^1.5.1:
+base64-js@1.5.1, base64-js@^1.2.3, base64-js@^1.3.0, base64-js@^1.3.1, base64-js@^1.5.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
@@ -8861,6 +8861,18 @@ expo-audio@~0.4.9:
resolved "https://registry.yarnpkg.com/expo-audio/-/expo-audio-0.4.9.tgz#f15f64652785ecd416ad351bf42666315e1e0b69"
integrity sha512-J4mMYEt2mqRqqwmSsXFylMGlrNWa+MbCzGl1IZBs+smvPAMJ3Ni8fNplzCQ0I9RnRzygKhRwJNpnAVL+n4MuyA==
+expo-auth-session@~6.2.1:
+ version "6.2.1"
+ resolved "https://registry.yarnpkg.com/expo-auth-session/-/expo-auth-session-6.2.1.tgz#27c645575ce98508ed8a0faf2c586b04e1a1ba15"
+ integrity sha512-9KgqrGpW7PoNOhxJ7toofi/Dz5BU2TE4Q+ktJZsmDXLoFcNOcvBokh2+mkhG58Qvd/xJ9Z5sAt/5QoOFaPb9wA==
+ dependencies:
+ expo-application "~6.1.5"
+ expo-constants "~17.1.7"
+ expo-crypto "~14.1.5"
+ expo-linking "~7.1.7"
+ expo-web-browser "~14.2.0"
+ invariant "^2.2.4"
+
expo-av@~15.1.7:
version "15.1.7"
resolved "https://registry.yarnpkg.com/expo-av/-/expo-av-15.1.7.tgz#a8422646eca9250c842e8a44fccccb1a4b070a05"
@@ -8890,6 +8902,13 @@ expo-constants@~17.1.8:
"@expo/config" "~11.0.13"
"@expo/env" "~1.0.7"
+expo-crypto@~14.1.5:
+ version "14.1.5"
+ resolved "https://registry.yarnpkg.com/expo-crypto/-/expo-crypto-14.1.5.tgz#1c29ddd4657d96af6358a9ecdc85a0c344c9ae0c"
+ integrity sha512-ZXJoUMoUeiMNEoSD4itItFFz3cKrit6YJ/BR0hjuwNC+NczbV9rorvhvmeJmrU9O2cFQHhJQQR1fjQnt45Vu4Q==
+ dependencies:
+ base64-js "^1.3.0"
+
expo-dev-client@~5.2.4:
version "5.2.4"
resolved "https://registry.yarnpkg.com/expo-dev-client/-/expo-dev-client-5.2.4.tgz#cdffaea81841b2903cb9585bdd1566dea275a097"
@@ -9064,6 +9083,11 @@ expo-screen-orientation@~8.1.7:
resolved "https://registry.yarnpkg.com/expo-screen-orientation/-/expo-screen-orientation-8.1.7.tgz#3751b441f2bfcbde798b1508c0ff9f099f4be911"
integrity sha512-nYwadYtdU6mMDk0MCHMPPPQtBoeFYJ2FspLRW+J35CMLqzE4nbpwGeiImfXzkvD94fpOCfI4KgLj5vGauC3pfA==
+expo-secure-store@~14.2.4:
+ version "14.2.4"
+ resolved "https://registry.yarnpkg.com/expo-secure-store/-/expo-secure-store-14.2.4.tgz#673743567a6459fb4b5f9406d57d9a3b16bca69f"
+ integrity sha512-ePaz4fnTitJJZjAiybaVYGfLWWyaEtepZC+vs9ZBMhQMfG5HUotIcVsDaSo3FnwpHmgwsLVPY2qFeryI6AtULw==
+
expo-sharing@~13.1.5:
version "13.1.5"
resolved "https://registry.yarnpkg.com/expo-sharing/-/expo-sharing-13.1.5.tgz#73d86cdcc037b46ddc82be224dfd3d6bceec497c"
@@ -9104,6 +9128,11 @@ expo-updates-interface@~1.1.0:
resolved "https://registry.yarnpkg.com/expo-updates-interface/-/expo-updates-interface-1.1.0.tgz#62497d4647b381da9fdb68868ed180203ae737ef"
integrity sha512-DeB+fRe0hUDPZhpJ4X4bFMAItatFBUPjw/TVSbJsaf3Exeami+2qbbJhWkcTMoYHOB73nOIcaYcWXYJnCJXO0w==
+expo-web-browser@~14.2.0:
+ version "14.2.0"
+ resolved "https://registry.yarnpkg.com/expo-web-browser/-/expo-web-browser-14.2.0.tgz#d8fb521ae349aebbf5c0ca32448877480124c06c"
+ integrity sha512-6S51d8pVlDRDsgGAp8BPpwnxtyKiMWEFdezNz+5jVIyT+ctReW42uxnjRgtsdn5sXaqzhaX+Tzk/CWaKCyC0hw==
+
expo@~53.0.27:
version "53.0.27"
resolved "https://registry.yarnpkg.com/expo/-/expo-53.0.27.tgz#d42b14ad23388bd8480c3b84be7558b9a2224c9d"