Skip to content

Commit ff80618

Browse files
committed
RU-T48 PR#231 fixes
1 parent 10120a6 commit ff80618

File tree

4 files changed

+28
-77
lines changed

4 files changed

+28
-77
lines changed

src/app/login/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export default function Login() {
4747

4848
// ── Local login ───────────────────────────────────────────────────────────
4949
const onSubmit: LoginFormProps['onSubmit'] = async (data) => {
50-
logger.info({ message: 'Starting Login (button press)', context: { username: data.username } });
50+
logger.info({ message: 'Starting Login (button press)' });
5151
await login({ username: data.username, password: data.password });
5252
};
5353

src/app/login/sso.tsx

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export default function SsoLogin() {
4646
clientId: ssoConfig?.clientId ?? '',
4747
});
4848

49-
const { startSamlLogin, handleDeepLink, isSamlCallback } = useSamlLogin();
49+
const { startSamlLogin, isSamlCallback } = useSamlLogin();
5050

5151
const {
5252
control,
@@ -73,16 +73,16 @@ export default function SsoLogin() {
7373

7474
setIsSsoLoading(true);
7575
oidc
76-
.exchangeForResgridToken(pendingUsernameRef.current)
77-
.then((result) => {
78-
if (!result) {
76+
.exchangeForResgridToken()
77+
.then((idToken) => {
78+
if (!idToken) {
7979
setIsSsoLoading(false);
8080
setIsErrorModalVisible(true);
8181
return;
8282
}
8383
ssoLogin({
8484
provider: 'oidc',
85-
externalToken: result.access_token,
85+
externalToken: idToken,
8686
username: pendingUsernameRef.current,
8787
});
8888
})
@@ -97,16 +97,17 @@ export default function SsoLogin() {
9797
useEffect(() => {
9898
const subscription = Linking.addEventListener('url', async ({ url }: { url: string }) => {
9999
if (!isSamlCallback(url)) return;
100-
setIsSsoLoading(true);
101-
const result = await handleDeepLink(url, pendingUsernameRef.current);
102-
if (!result) {
100+
const parsed = Linking.parse(url);
101+
const samlResponse = parsed.queryParams?.saml_response as string | undefined;
102+
if (!samlResponse) {
103103
setIsSsoLoading(false);
104104
setIsErrorModalVisible(true);
105105
return;
106106
}
107+
setIsSsoLoading(true);
107108
await ssoLogin({
108109
provider: 'saml2',
109-
externalToken: result.access_token,
110+
externalToken: samlResponse,
110111
username: pendingUsernameRef.current,
111112
});
112113
});

src/hooks/__tests__/use-oidc-login.test.ts

Lines changed: 8 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,15 @@
11
import { renderHook } from '@testing-library/react-native';
2-
import axios from 'axios';
32
import * as AuthSession from 'expo-auth-session';
4-
import * as WebBrowser from 'expo-web-browser';
53

64
import { useOidcLogin } from '../use-oidc-login';
75

86
jest.mock('expo-auth-session');
97
jest.mock('expo-web-browser');
10-
jest.mock('axios');
11-
jest.mock('@/lib/storage/app', () => ({
12-
getBaseApiUrl: jest.fn(() => 'https://api.resgrid.com/api/v4'),
13-
}));
148
jest.mock('@/lib/logging', () => ({
159
logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() },
1610
}));
1711

1812
const mockedAuthSession = AuthSession as jest.Mocked<typeof AuthSession>;
19-
const mockedAxios = axios as jest.Mocked<typeof axios>;
2013

2114
describe('useOidcLogin', () => {
2215
const mockPromptAsync = jest.fn();
@@ -61,7 +54,7 @@ describe('useOidcLogin', () => {
6154
useOidcLogin({ authority: 'https://idp.example.com', clientId: 'client123' }),
6255
);
6356

64-
const tokenResult = await result.current.exchangeForResgridToken('john.doe');
57+
const tokenResult = await result.current.exchangeForResgridToken();
6558
expect(tokenResult).toBeNull();
6659
});
6760

@@ -81,11 +74,11 @@ describe('useOidcLogin', () => {
8174
useOidcLogin({ authority: 'https://idp.example.com', clientId: 'client123' }),
8275
);
8376

84-
const tokenResult = await result.current.exchangeForResgridToken('john.doe');
77+
const tokenResult = await result.current.exchangeForResgridToken();
8578
expect(tokenResult).toBeNull();
8679
});
8780

88-
it('exchanges id_token for Resgrid token on success', async () => {
81+
it('returns the IdP id_token string on success', async () => {
8982
(mockedAuthSession.useAuthRequest as jest.Mock).mockReturnValue([
9083
{ codeVerifier: 'verifier123' },
9184
{ type: 'success', params: { code: 'auth-code-123' } },
@@ -97,53 +90,29 @@ describe('useOidcLogin', () => {
9790
accessToken: 'oidc-access',
9891
});
9992

100-
mockedAxios.post = jest.fn().mockResolvedValueOnce({
101-
data: {
102-
access_token: 'rg-access',
103-
refresh_token: 'rg-refresh',
104-
expires_in: 3600,
105-
token_type: 'Bearer',
106-
},
107-
});
108-
10993
const { result } = renderHook(() =>
11094
useOidcLogin({ authority: 'https://idp.example.com', clientId: 'client123' }),
11195
);
11296

113-
const tokenResult = await result.current.exchangeForResgridToken('john.doe');
114-
115-
expect(tokenResult).toEqual({
116-
access_token: 'rg-access',
117-
refresh_token: 'rg-refresh',
118-
expires_in: 3600,
119-
token_type: 'Bearer',
120-
});
97+
const tokenResult = await result.current.exchangeForResgridToken();
12198

122-
expect(mockedAxios.post).toHaveBeenCalledWith(
123-
'https://api.resgrid.com/api/v4/connect/external-token',
124-
expect.stringContaining('external_token=oidc-id-token'),
125-
expect.objectContaining({ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }),
126-
);
99+
expect(tokenResult).toBe('oidc-id-token');
127100
});
128101

129-
it('returns null when Resgrid API call fails', async () => {
102+
it('returns null when IdP code exchange fails', async () => {
130103
(mockedAuthSession.useAuthRequest as jest.Mock).mockReturnValue([
131104
{ codeVerifier: 'verifier123' },
132105
{ type: 'success', params: { code: 'auth-code-123' } },
133106
mockPromptAsync,
134107
]);
135108

136-
(mockedAuthSession.exchangeCodeAsync as jest.Mock).mockResolvedValueOnce({
137-
idToken: 'oidc-id-token',
138-
});
139-
140-
mockedAxios.post = jest.fn().mockRejectedValueOnce(new Error('API Error'));
109+
(mockedAuthSession.exchangeCodeAsync as jest.Mock).mockRejectedValueOnce(new Error('IdP Error'));
141110

142111
const { result } = renderHook(() =>
143112
useOidcLogin({ authority: 'https://idp.example.com', clientId: 'client123' }),
144113
);
145114

146-
const tokenResult = await result.current.exchangeForResgridToken('john.doe');
115+
const tokenResult = await result.current.exchangeForResgridToken();
147116
expect(tokenResult).toBeNull();
148117
});
149118
});

src/hooks/use-oidc-login.ts

Lines changed: 9 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
1-
import axios from 'axios';
21
import * as AuthSession from 'expo-auth-session';
32
import * as WebBrowser from 'expo-web-browser';
43

54
import { logger } from '@/lib/logging';
6-
import { getBaseApiUrl } from '@/lib/storage/app';
75

86
// Required for iOS / Android to close the browser after redirect
97
WebBrowser.maybeCompleteAuthSession();
@@ -13,26 +11,18 @@ export interface UseOidcLoginOptions {
1311
clientId: string;
1412
}
1513

16-
export interface OidcExchangeResult {
17-
access_token: string;
18-
refresh_token: string;
19-
id_token?: string;
20-
expires_in: number;
21-
token_type: string;
22-
expiration_date?: string;
23-
}
24-
2514
/**
2615
* Hook that drives the OIDC Authorization-Code + PKCE flow.
2716
*
2817
* Usage:
2918
* const { request, promptAsync, exchangeForResgridToken } = useOidcLogin({ authority, clientId });
3019
* // 1. call promptAsync() on button press
31-
* // 2. watch response inside a useEffect and call exchangeForResgridToken(username) when type === 'success'
20+
* // 2. watch response inside a useEffect and call exchangeForResgridToken() when type === 'success'
21+
* // 3. pass the returned id_token to ssoLogin({ provider: 'oidc', externalToken: idToken, username })
3222
*/
3323
export function useOidcLogin({ authority, clientId }: UseOidcLoginOptions) {
3424
const redirectUri = AuthSession.makeRedirectUri({
35-
scheme: 'ResgridUnit',
25+
scheme: 'resgridunit',
3626
path: 'auth/callback',
3727
});
3828

@@ -50,10 +40,11 @@ export function useOidcLogin({ authority, clientId }: UseOidcLoginOptions) {
5040
);
5141

5242
/**
53-
* Exchange the OIDC authorization code for a Resgrid access token.
43+
* Exchange the OIDC authorization code for the IdP id_token.
5444
* Should be called after `response?.type === 'success'`.
45+
* Returns the raw id_token string for use as the externalToken in ssoLogin.
5546
*/
56-
async function exchangeForResgridToken(username: string): Promise<OidcExchangeResult | null> {
47+
async function exchangeForResgridToken(): Promise<string | null> {
5748
if (response?.type !== 'success' || !request?.codeVerifier || !discovery) {
5849
logger.warn({
5950
message: 'OIDC exchange called in invalid state',
@@ -63,7 +54,7 @@ export function useOidcLogin({ authority, clientId }: UseOidcLoginOptions) {
6354
}
6455

6556
try {
66-
// Step 1: exchange auth code for id_token at the IdP
57+
// Exchange auth code for id_token at the IdP
6758
const tokenResponse = await AuthSession.exchangeCodeAsync(
6859
{
6960
clientId,
@@ -80,18 +71,8 @@ export function useOidcLogin({ authority, clientId }: UseOidcLoginOptions) {
8071
return null;
8172
}
8273

83-
// Step 2: exchange id_token for Resgrid access/refresh tokens
84-
const params = new URLSearchParams({
85-
provider: 'oidc',
86-
external_token: idToken,
87-
username,
88-
scope: 'openid email profile offline_access mobile',
89-
});
90-
91-
const resgridResponse = await axios.post<OidcExchangeResult>(`${getBaseApiUrl()}/connect/external-token`, params.toString(), { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } });
92-
93-
logger.info({ message: 'OIDC Resgrid token exchange successful' });
94-
return resgridResponse.data;
74+
logger.info({ message: 'OIDC code exchange successful, id_token obtained' });
75+
return idToken;
9576
} catch (error) {
9677
logger.error({
9778
message: 'OIDC token exchange failed',

0 commit comments

Comments
 (0)