diff --git a/app/lib/methods/helpers/parseSamlOrCasRedirect.test.ts b/app/lib/methods/helpers/parseSamlOrCasRedirect.test.ts new file mode 100644 index 00000000000..89d5a72f52a --- /dev/null +++ b/app/lib/methods/helpers/parseSamlOrCasRedirect.test.ts @@ -0,0 +1,70 @@ +import { parseSamlOrCasRedirect } from './parseSamlOrCasRedirect'; + +describe('parseSamlOrCasRedirect', () => { + describe('SAML', () => { + it('returns a saml match when authType is saml and the URL has saml_idp_credentialToken', () => { + expect(parseSamlOrCasRedirect('https://server.example/_saml/callback?saml_idp_credentialToken=abc123', 'saml')).toEqual({ + kind: 'saml', + payload: { credentialToken: 'abc123', saml: true } + }); + }); + + it('uses the URL token even when an ssoToken is also provided', () => { + expect( + parseSamlOrCasRedirect('https://server.example/_saml/callback?saml_idp_credentialToken=abc123', 'saml', 'fallback') + ).toEqual({ + kind: 'saml', + payload: { credentialToken: 'abc123', saml: true } + }); + }); + + it('returns null when authType is saml but the URL has no saml_idp_credentialToken', () => { + expect(parseSamlOrCasRedirect('https://server.example/login', 'saml')).toBeNull(); + }); + + it('returns null when authType is saml and the URL only has a CAS-style ticket', () => { + expect(parseSamlOrCasRedirect('https://server.example/_cas/callback?ticket=ST-123', 'saml')).toBeNull(); + }); + }); + + describe('CAS', () => { + it('returns a cas match when authType is cas and the URL pathname includes validate', () => { + expect(parseSamlOrCasRedirect('https://server.example/_cas/validate/xyz', 'cas', 'sso-token')).toEqual({ + kind: 'cas', + payload: { cas: { credentialToken: 'sso-token' } } + }); + }); + + it('returns a cas match when authType is cas and the URL has a ticket query param', () => { + expect(parseSamlOrCasRedirect('https://server.example/_cas/callback?ticket=ST-123', 'cas', 'sso-token')).toEqual({ + kind: 'cas', + payload: { cas: { credentialToken: 'sso-token' } } + }); + }); + + it('returns null when authType is cas but the URL has neither validate nor a ticket', () => { + expect(parseSamlOrCasRedirect('https://server.example/login', 'cas', 'sso-token')).toBeNull(); + }); + + it('passes credentialToken through as undefined when ssoToken is not provided', () => { + expect(parseSamlOrCasRedirect('https://server.example/_cas/validate/xyz', 'cas')).toEqual({ + kind: 'cas', + payload: { cas: { credentialToken: undefined } } + }); + }); + + it('returns null when authType is cas and the URL only has a SAML-style token', () => { + expect(parseSamlOrCasRedirect('https://server.example/_saml/callback?saml_idp_credentialToken=abc', 'cas')).toBeNull(); + }); + }); + + describe('other auth types', () => { + it('returns null for oauth', () => { + expect(parseSamlOrCasRedirect('https://server.example/_saml/callback?saml_idp_credentialToken=abc', 'oauth')).toBeNull(); + }); + + it('returns null for iframe', () => { + expect(parseSamlOrCasRedirect('https://server.example/_cas/validate/xyz', 'iframe', 'sso-token')).toBeNull(); + }); + }); +}); diff --git a/app/lib/methods/helpers/parseSamlOrCasRedirect.ts b/app/lib/methods/helpers/parseSamlOrCasRedirect.ts new file mode 100644 index 00000000000..99d9c59c9a0 --- /dev/null +++ b/app/lib/methods/helpers/parseSamlOrCasRedirect.ts @@ -0,0 +1,17 @@ +import parse from 'url-parse'; + +import { type ICredentials } from '../../../definitions'; + +export type SamlOrCasRedirect = { kind: 'saml'; payload: ICredentials } | { kind: 'cas'; payload: ICredentials } | null; + +export const parseSamlOrCasRedirect = (url: string, authType: string, ssoToken?: string): SamlOrCasRedirect => { + const parsedUrl = parse(url, true); + if (authType === 'saml' && parsedUrl.query?.saml_idp_credentialToken) { + const token = parsedUrl.query.saml_idp_credentialToken || ssoToken; + return { kind: 'saml', payload: { credentialToken: token, saml: true } }; + } + if (authType === 'cas' && (parsedUrl.pathname?.includes('validate') || parsedUrl.query?.ticket)) { + return { kind: 'cas', payload: { cas: { credentialToken: ssoToken } } }; + } + return null; +}; diff --git a/app/views/AuthenticationWebView.tsx b/app/views/AuthenticationWebView.tsx index 74e28c92385..6017ea9e9ba 100644 --- a/app/views/AuthenticationWebView.tsx +++ b/app/views/AuthenticationWebView.tsx @@ -1,7 +1,7 @@ import { type RouteProp } from '@react-navigation/core'; import { useNavigation, useRoute } from '@react-navigation/native'; import { type NativeStackNavigationProp } from '@react-navigation/native-stack'; -import React, { useLayoutEffect, useState } from 'react'; +import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { WebView, type WebViewNavigation } from 'react-native-webview'; import { type WebViewMessage } from 'react-native-webview/lib/WebViewTypes'; import parse from 'url-parse'; @@ -15,6 +15,7 @@ import { useDebounce } from '../lib/methods/helpers'; import { loginOAuthOrSso } from '../lib/services/connect'; import { type OutsideModalParamList } from '../stacks/types'; import fetch, { type TMethods } from '../lib/methods/helpers/fetch'; +import { parseSamlOrCasRedirect } from '../lib/methods/helpers/parseSamlOrCasRedirect'; // iframe uses a postMessage to send the token to the client // We'll handle this sending the token to the hash of the window.location @@ -43,15 +44,21 @@ window.addEventListener('popstate', function() { const SSO_AUTH_TYPES = ['saml', 'cas', 'iframe']; const AuthenticationWebView = () => { - const [logging, setLogging] = useState(false); const [loading, setLoading] = useState(false); const [headerTitle, setHeaderTitle] = useState(null); + const loggingRef = useRef(false); + const redirectHandledRef = useRef(false); const navigation = useNavigation>(); const { params: { authType, url, ssoToken } } = useRoute>(); + // Reset redirect guard when auth params change (e.g., user logs out and retries) + useEffect(() => { + redirectHandledRef.current = false; + }, [url, authType, ssoToken]); + const { Accounts_Iframe_api_method, Accounts_Iframe_api_url, server } = useAppSelector(state => ({ server: state.server.server, Accounts_Iframe_api_url: state.settings.Accounts_Iframe_api_url as string, @@ -64,18 +71,19 @@ const AuthenticationWebView = () => { // Force 3s delay so the server has time to evaluate the token const debouncedLogin = useDebounce((params: ICredentials) => login(params), 3000); - const login = (params: ICredentials) => { - if (logging) { + const login = async (params: ICredentials) => { + if (loggingRef.current) { return; } - setLogging(true); + loggingRef.current = true; try { - loginOAuthOrSso(params); + await loginOAuthOrSso(params); } catch (e) { console.warn(e); + } finally { + loggingRef.current = false; + navigation.pop(); } - setLogging(false); - navigation.pop(); }; const tryLogin = useDebounce( @@ -92,6 +100,19 @@ const AuthenticationWebView = () => { { leading: true } ); + const handleSamlOrCasRedirect = (url: string): boolean => { + const result = parseSamlOrCasRedirect(url, authType, ssoToken); + if (!result) return false; + if (redirectHandledRef.current) return true; + redirectHandledRef.current = true; + if (result.kind === 'saml') { + login(result.payload); + } else { + debouncedLogin(result.payload); + } + return true; + }; + const onNavigationStateChange = (webViewState: WebViewNavigation | WebViewMessage) => { const url = decodeURIComponent(webViewState.url); @@ -104,19 +125,8 @@ const AuthenticationWebView = () => { } } if (authType === 'saml' || authType === 'cas') { - const parsedUrl = parse(url, true); - // ticket -> cas / validate & saml_idp_credentialToken -> saml - if (parsedUrl.pathname?.includes('validate') || parsedUrl.query?.ticket || parsedUrl.query?.saml_idp_credentialToken) { - let payload: ICredentials; - if (authType === 'saml') { - const token = parsedUrl.query?.saml_idp_credentialToken || ssoToken; - const credentialToken = { credentialToken: token }; - payload = { ...credentialToken, saml: true }; - } else { - payload = { cas: { credentialToken: ssoToken } }; - } - debouncedLogin(payload); - } + handleSamlOrCasRedirect(url); + return; } if (authType === 'oauth') { @@ -166,6 +176,12 @@ const AuthenticationWebView = () => { // https://github.com/react-native-community/react-native-webview/issues/24#issuecomment-540130141 onMessage={({ nativeEvent }) => onNavigationStateChange(nativeEvent)} onNavigationStateChange={onNavigationStateChange} + onShouldStartLoadWithRequest={req => { + if (authType === 'saml') { + return !handleSamlOrCasRedirect(req.url); + } + return true; + }} injectedJavaScript={isIframe ? injectedJavaScript : undefined} onLoadStart={() => setLoading(true)} onLoadEnd={() => setLoading(false)}