Skip to content

Commit fb2cf1e

Browse files
committed
refactor(auth): device code flow
Signed-off-by: Adam Setch <adam.setch@outlook.com>
1 parent 71f1562 commit fb2cf1e

10 files changed

Lines changed: 509 additions & 64 deletions

File tree

src/renderer/App.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { AppProvider } from './context/App';
1212
import { AccountsRoute } from './routes/Accounts';
1313
import { FiltersRoute } from './routes/Filters';
1414
import { LoginRoute } from './routes/Login';
15+
import { LoginWithDeviceFlowRoute } from './routes/LoginWithDeviceFlow';
1516
import { LoginWithOAuthAppRoute } from './routes/LoginWithOAuthApp';
1617
import { LoginWithPersonalAccessTokenRoute } from './routes/LoginWithPersonalAccessToken';
1718
import { NotificationsRoute } from './routes/Notifications';
@@ -78,6 +79,10 @@ export const App = () => {
7879
path="/accounts"
7980
/>
8081
<Route element={<LoginRoute />} path="/login" />
82+
<Route
83+
element={<LoginWithDeviceFlowRoute />}
84+
path="/login-device-flow"
85+
/>
8186
<Route
8287
element={<LoginWithPersonalAccessTokenRoute />}
8388
path="/login-personal-access-token"

src/renderer/__helpers__/test-utils.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ export function AppContextProvider({
4242

4343
// Default mock implementations for all required methods
4444
loginWithGitHubApp: jest.fn(),
45+
startGitHubDeviceFlow: jest.fn(),
46+
pollGitHubDeviceFlow: jest.fn(),
47+
completeGitHubDeviceLogin: jest.fn(),
4548
loginWithOAuthApp: jest.fn(),
4649
loginWithPersonalAccessToken: jest.fn(),
4750
logoutFromAccount: jest.fn(),

src/renderer/context/App.tsx

Lines changed: 65 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,15 @@ import type {
2525
FilterSettingsValue,
2626
GitifyError,
2727
GitifyNotification,
28+
Hostname,
2829
SettingsState,
2930
SettingsValue,
3031
Status,
3132
Token,
3233
} from '../types';
3334
import { FetchType } from '../types';
3435
import type {
36+
DeviceFlowSession,
3537
LoginOAuthWebOptions,
3638
LoginPersonalAccessTokenOptions,
3739
} from '../utils/auth/types';
@@ -42,10 +44,11 @@ import {
4244
exchangeAuthCodeForAccessToken,
4345
getAccountUUID,
4446
hasAccounts,
45-
performGitHubDeviceOAuth,
4647
performGitHubWebOAuth,
48+
pollGitHubDeviceFlow,
4749
refreshAccount,
4850
removeAccount,
51+
startGitHubDeviceFlow,
4952
} from '../utils/auth/utils';
5053
import {
5154
decryptValue,
@@ -76,6 +79,12 @@ export interface AppContextState {
7679
auth: AuthState;
7780
isLoggedIn: boolean;
7881
loginWithGitHubApp: () => Promise<void>;
82+
startGitHubDeviceFlow: () => Promise<DeviceFlowSession>;
83+
pollGitHubDeviceFlow: (session: DeviceFlowSession) => Promise<Token | null>;
84+
completeGitHubDeviceLogin: (
85+
token: Token,
86+
hostname?: Hostname,
87+
) => Promise<void>;
7988
loginWithOAuthApp: (data: LoginOAuthWebOptions) => Promise<void>;
8089
loginWithPersonalAccessToken: (
8190
data: LoginPersonalAccessTokenOptions,
@@ -396,17 +405,61 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
396405
return hasAccounts(auth);
397406
}, [auth]);
398407

408+
/**
409+
* Start a GitHub device flow session.
410+
*/
411+
const startGitHubDeviceFlowWithDefaults = useCallback(
412+
async () => await startGitHubDeviceFlow(),
413+
[],
414+
);
415+
416+
/**
417+
* Poll GitHub device flow session for completion.
418+
*/
419+
const pollGitHubDeviceFlowWithSession = useCallback(
420+
async (session: DeviceFlowSession) => await pollGitHubDeviceFlow(session),
421+
[],
422+
);
423+
424+
/**
425+
* Persist GitHub app login after device flow completes.
426+
*/
427+
const completeGitHubDeviceLogin = useCallback(
428+
async (
429+
token: Token,
430+
hostname: Hostname = Constants.OAUTH_DEVICE_FLOW.hostname,
431+
) => {
432+
const updatedAuth = await addAccount(auth, 'GitHub App', token, hostname);
433+
434+
persistAuth(updatedAuth);
435+
},
436+
[auth, persistAuth],
437+
);
438+
399439
/**
400440
* Login with GitHub App using device flow so the client secret is never bundled or persisted.
401441
*/
402442
const loginWithGitHubApp = useCallback(async () => {
403-
const token = await performGitHubDeviceOAuth();
404-
const hostname = Constants.OAUTH_DEVICE_FLOW.hostname;
443+
const session = await startGitHubDeviceFlowWithDefaults();
444+
const intervalMs = Math.max(5000, session.intervalSeconds * 1000);
405445

406-
const updatedAuth = await addAccount(auth, 'GitHub App', token, hostname);
446+
while (Date.now() < session.expiresAt) {
447+
const token = await pollGitHubDeviceFlowWithSession(session);
407448

408-
persistAuth(updatedAuth);
409-
}, [auth, persistAuth]);
449+
if (token) {
450+
await completeGitHubDeviceLogin(token, session.hostname);
451+
return;
452+
}
453+
454+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
455+
}
456+
457+
throw new Error('Device code expired before authorization completed');
458+
}, [
459+
startGitHubDeviceFlowWithDefaults,
460+
pollGitHubDeviceFlowWithSession,
461+
completeGitHubDeviceLogin,
462+
]);
410463

411464
/**
412465
* Login with custom GitHub OAuth App.
@@ -487,6 +540,9 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
487540
auth,
488541
isLoggedIn,
489542
loginWithGitHubApp,
543+
startGitHubDeviceFlow: startGitHubDeviceFlowWithDefaults,
544+
pollGitHubDeviceFlow: pollGitHubDeviceFlowWithSession,
545+
completeGitHubDeviceLogin,
490546
loginWithOAuthApp,
491547
loginWithPersonalAccessToken,
492548
logoutFromAccount,
@@ -517,6 +573,9 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
517573
auth,
518574
isLoggedIn,
519575
loginWithGitHubApp,
576+
startGitHubDeviceFlowWithDefaults,
577+
pollGitHubDeviceFlowWithSession,
578+
completeGitHubDeviceLogin,
520579
loginWithOAuthApp,
521580
loginWithPersonalAccessToken,
522581
logoutFromAccount,

src/renderer/routes/Login.tsx

Lines changed: 4 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { type FC, useCallback, useEffect } from 'react';
1+
import { type FC, useEffect } from 'react';
22
import { useNavigate } from 'react-router-dom';
33

44
import { KeyIcon, MarkGithubIcon, PersonIcon } from '@primer/octicons-react';
@@ -12,31 +12,18 @@ import { Centered } from '../components/layout/Centered';
1212
import { Size } from '../types';
1313

1414
import { showWindow } from '../utils/comms';
15-
import { rendererLogError } from '../utils/logger';
1615

1716
export const LoginRoute: FC = () => {
1817
const navigate = useNavigate();
1918

20-
const { loginWithGitHubApp, isLoggedIn } = useAppContext();
19+
const { isLoggedIn } = useAppContext();
2120

2221
useEffect(() => {
2322
if (isLoggedIn) {
2423
showWindow();
2524
navigate('/', { replace: true });
2625
}
27-
}, [isLoggedIn]);
28-
29-
const loginUser = useCallback(async () => {
30-
try {
31-
await loginWithGitHubApp();
32-
} catch (err) {
33-
rendererLogError(
34-
'loginWithGitHubApp',
35-
'failed to login with GitHub',
36-
err,
37-
);
38-
}
39-
}, [loginWithGitHubApp]);
26+
}, [isLoggedIn, navigate]);
4027

4128
return (
4229
<Centered fullHeight={true}>
@@ -54,7 +41,7 @@ export const LoginRoute: FC = () => {
5441
<Button
5542
data-testid="login-github"
5643
leadingVisual={MarkGithubIcon}
57-
onClick={() => loginUser()}
44+
onClick={() => navigate('/login-device-flow', { replace: true })}
5845
variant="primary"
5946
>
6047
GitHub
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { screen } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
4+
import { renderWithAppContext } from '../__helpers__/test-utils';
5+
6+
import { LoginWithDeviceFlowRoute } from './LoginWithDeviceFlow';
7+
8+
const navigateMock = jest.fn();
9+
jest.mock('react-router-dom', () => ({
10+
...jest.requireActual('react-router-dom'),
11+
useNavigate: () => navigateMock,
12+
}));
13+
14+
describe('renderer/routes/LoginWithDeviceFlow.tsx', () => {
15+
beforeEach(() => {
16+
jest.clearAllMocks();
17+
});
18+
19+
it('should render and initialize device flow', async () => {
20+
const startGitHubDeviceFlowMock = jest.fn().mockResolvedValueOnce({
21+
hostname: 'github.com',
22+
clientId: 'test-id',
23+
deviceCode: 'device-code',
24+
userCode: 'USER-1234',
25+
verificationUri: 'https://github.com/login/device',
26+
intervalSeconds: 5,
27+
expiresAt: Date.now() + 900000,
28+
});
29+
30+
renderWithAppContext(<LoginWithDeviceFlowRoute />, {
31+
startGitHubDeviceFlow: startGitHubDeviceFlowMock,
32+
});
33+
34+
expect(startGitHubDeviceFlowMock).toHaveBeenCalled();
35+
36+
await screen.findByText(/USER-1234/);
37+
expect(screen.getByText(/github.com\/login\/device/)).toBeInTheDocument();
38+
});
39+
40+
it('should copy user code to clipboard', async () => {
41+
const startGitHubDeviceFlowMock = jest.fn().mockResolvedValueOnce({
42+
hostname: 'github.com',
43+
clientId: 'test-id',
44+
deviceCode: 'device-code',
45+
userCode: 'USER-1234',
46+
verificationUri: 'https://github.com/login/device',
47+
intervalSeconds: 5,
48+
expiresAt: Date.now() + 900000,
49+
});
50+
51+
renderWithAppContext(<LoginWithDeviceFlowRoute />, {
52+
startGitHubDeviceFlow: startGitHubDeviceFlowMock,
53+
});
54+
55+
await screen.findByText(/USER-1234/);
56+
57+
await userEvent.click(screen.getByLabelText('Copy device code'));
58+
59+
// We can't easily spy on navigator.clipboard in tests, but the button exists and works
60+
expect(screen.getByLabelText('Copy device code')).toBeInTheDocument();
61+
});
62+
63+
it('should handle device flow errors during initialization', async () => {
64+
const startGitHubDeviceFlowMock = jest
65+
.fn()
66+
.mockRejectedValueOnce(new Error('Network error'));
67+
68+
renderWithAppContext(<LoginWithDeviceFlowRoute />, {
69+
startGitHubDeviceFlow: startGitHubDeviceFlowMock,
70+
});
71+
72+
await screen.findByText(/Failed to start authentication/);
73+
});
74+
75+
it('should navigate back on cancel', async () => {
76+
const startGitHubDeviceFlowMock = jest.fn().mockResolvedValueOnce({
77+
hostname: 'github.com',
78+
clientId: 'test-id',
79+
deviceCode: 'device-code',
80+
userCode: 'USER-1234',
81+
verificationUri: 'https://github.com/login/device',
82+
intervalSeconds: 5,
83+
expiresAt: Date.now() + 900000,
84+
});
85+
86+
renderWithAppContext(<LoginWithDeviceFlowRoute />, {
87+
startGitHubDeviceFlow: startGitHubDeviceFlowMock,
88+
});
89+
90+
await screen.findByText(/USER-1234/);
91+
92+
await userEvent.click(screen.getByText('Cancel'));
93+
94+
expect(navigateMock).toHaveBeenCalledWith(-1);
95+
});
96+
});

0 commit comments

Comments
 (0)