Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { AppProvider } from './context/App';
import { AccountsRoute } from './routes/Accounts';
import { FiltersRoute } from './routes/Filters';
import { LoginRoute } from './routes/Login';
import { LoginWithDeviceFlowRoute } from './routes/LoginWithDeviceFlow';
import { LoginWithOAuthAppRoute } from './routes/LoginWithOAuthApp';
import { LoginWithPersonalAccessTokenRoute } from './routes/LoginWithPersonalAccessToken';
import { NotificationsRoute } from './routes/Notifications';
Expand Down Expand Up @@ -78,6 +79,10 @@ export const App = () => {
path="/accounts"
/>
<Route element={<LoginRoute />} path="/login" />
<Route
element={<LoginWithDeviceFlowRoute />}
path="/login-device-flow"
/>
<Route
element={<LoginWithPersonalAccessTokenRoute />}
path="/login-personal-access-token"
Expand Down
3 changes: 3 additions & 0 deletions src/renderer/__helpers__/test-utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ export function AppContextProvider({

// Default mock implementations for all required methods
loginWithGitHubApp: jest.fn(),
startGitHubDeviceFlow: jest.fn(),
pollGitHubDeviceFlow: jest.fn(),
completeGitHubDeviceLogin: jest.fn(),
loginWithOAuthApp: jest.fn(),
loginWithPersonalAccessToken: jest.fn(),
logoutFromAccount: jest.fn(),
Expand Down
6 changes: 3 additions & 3 deletions src/renderer/__mocks__/account-mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export const mockGitHubAppAccount: Account = {
platform: 'GitHub Cloud',
method: 'GitHub App',
token: 'token-987654321' as Token,
hostname: Constants.DEFAULT_AUTH_OPTIONS.hostname,
hostname: Constants.OAUTH_DEVICE_FLOW.hostname,
user: mockGitifyUser,
hasRequiredScopes: true,
};
Expand All @@ -23,7 +23,7 @@ export const mockPersonalAccessTokenAccount: Account = {
platform: 'GitHub Cloud',
method: 'Personal Access Token',
token: 'token-123-456' as Token,
hostname: Constants.DEFAULT_AUTH_OPTIONS.hostname,
hostname: Constants.OAUTH_DEVICE_FLOW.hostname,
user: mockGitifyUser,
hasRequiredScopes: true,
};
Expand All @@ -41,7 +41,7 @@ export const mockGitHubCloudAccount: Account = {
platform: 'GitHub Cloud',
method: 'Personal Access Token',
token: 'token-123-456' as Token,
hostname: Constants.DEFAULT_AUTH_OPTIONS.hostname,
hostname: Constants.OAUTH_DEVICE_FLOW.hostname,
user: mockGitifyUser,
version: 'latest',
hasRequiredScopes: true,
Expand Down
9 changes: 4 additions & 5 deletions src/renderer/constants.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ClientID, ClientSecret, Hostname, Link } from './types';
import type { LoginOAuthAppOptions } from './utils/auth/types';
import type { ClientID, Hostname, Link } from './types';
import type { LoginOAuthDeviceOptions } from './utils/auth/types';

export const Constants = {
STORAGE_KEY: 'gitify-storage',
Expand All @@ -10,11 +10,10 @@ export const Constants = {
ALTERNATE: ['read:user', 'notifications', 'public_repo'],
},

DEFAULT_AUTH_OPTIONS: {
OAUTH_DEVICE_FLOW: {
hostname: 'github.com' as Hostname,
clientId: process.env.OAUTH_CLIENT_ID as ClientID,
clientSecret: process.env.OAUTH_CLIENT_SECRET as ClientSecret,
} satisfies LoginOAuthAppOptions,
} satisfies LoginOAuthDeviceOptions,

GITHUB_API_BASE_URL: 'https://api.github.com',
GITHUB_API_GRAPHQL_URL: 'https://api.github.com/graphql',
Expand Down
86 changes: 71 additions & 15 deletions src/renderer/context/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,16 @@ import type {
FilterSettingsValue,
GitifyError,
GitifyNotification,
Hostname,
SettingsState,
SettingsValue,
Status,
Token,
} from '../types';
import { FetchType } from '../types';
import type {
LoginOAuthAppOptions,
DeviceFlowSession,
LoginOAuthWebOptions,
LoginPersonalAccessTokenOptions,
} from '../utils/auth/types';

Expand All @@ -42,9 +44,11 @@ import {
exchangeAuthCodeForAccessToken,
getAccountUUID,
hasAccounts,
performGitHubOAuth,
performGitHubWebOAuth,
pollGitHubDeviceFlow,
refreshAccount,
removeAccount,
startGitHubDeviceFlow,
} from '../utils/auth/utils';
import {
decryptValue,
Expand Down Expand Up @@ -75,7 +79,13 @@ export interface AppContextState {
auth: AuthState;
isLoggedIn: boolean;
loginWithGitHubApp: () => Promise<void>;
loginWithOAuthApp: (data: LoginOAuthAppOptions) => Promise<void>;
startGitHubDeviceFlow: () => Promise<DeviceFlowSession>;
pollGitHubDeviceFlow: (session: DeviceFlowSession) => Promise<Token | null>;
completeGitHubDeviceLogin: (
token: Token,
hostname?: Hostname,
) => Promise<void>;
loginWithOAuthApp: (data: LoginOAuthWebOptions) => Promise<void>;
loginWithPersonalAccessToken: (
data: LoginPersonalAccessTokenOptions,
) => Promise<void>;
Expand Down Expand Up @@ -396,27 +406,67 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
}, [auth]);

/**
* Login with GitHub App.
*
* Note: although we call this "Login with GitHub App", this function actually
* authenticates via a predefined "Gitify" GitHub OAuth App.
* Start a GitHub device flow session.
*/
const startGitHubDeviceFlowWithDefaults = useCallback(
async () => await startGitHubDeviceFlow(),
[],
);

/**
* Poll GitHub device flow session for completion.
*/
const pollGitHubDeviceFlowWithSession = useCallback(
async (session: DeviceFlowSession) => await pollGitHubDeviceFlow(session),
[],
);

/**
* Persist GitHub app login after device flow completes.
*/
const completeGitHubDeviceLogin = useCallback(
async (
token: Token,
hostname: Hostname = Constants.OAUTH_DEVICE_FLOW.hostname,
) => {
const updatedAuth = await addAccount(auth, 'GitHub App', token, hostname);

persistAuth(updatedAuth);
},
[auth, persistAuth],
);

/**
* Login with GitHub App using device flow so the client secret is never bundled or persisted.
*/
const loginWithGitHubApp = useCallback(async () => {
const { authCode } = await performGitHubOAuth();
const token = await exchangeAuthCodeForAccessToken(authCode);
const hostname = Constants.DEFAULT_AUTH_OPTIONS.hostname;
const session = await startGitHubDeviceFlowWithDefaults();
const intervalMs = Math.max(5000, session.intervalSeconds * 1000);

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

persistAuth(updatedAuth);
}, [auth, persistAuth]);
if (token) {
await completeGitHubDeviceLogin(token, session.hostname);
return;
}

await new Promise((resolve) => setTimeout(resolve, intervalMs));
}

throw new Error('Device code expired before authorization completed');
}, [
startGitHubDeviceFlowWithDefaults,
pollGitHubDeviceFlowWithSession,
completeGitHubDeviceLogin,
]);

/**
* Login with custom GitHub OAuth App.
*/
const loginWithOAuthApp = useCallback(
async (data: LoginOAuthAppOptions) => {
const { authOptions, authCode } = await performGitHubOAuth(data);
async (data: LoginOAuthWebOptions) => {
const { authOptions, authCode } = await performGitHubWebOAuth(data);
const token = await exchangeAuthCodeForAccessToken(authCode, authOptions);

const updatedAuth = await addAccount(
Expand Down Expand Up @@ -490,6 +540,9 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
auth,
isLoggedIn,
loginWithGitHubApp,
startGitHubDeviceFlow: startGitHubDeviceFlowWithDefaults,
pollGitHubDeviceFlow: pollGitHubDeviceFlowWithSession,
completeGitHubDeviceLogin,
loginWithOAuthApp,
loginWithPersonalAccessToken,
logoutFromAccount,
Expand Down Expand Up @@ -520,6 +573,9 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
auth,
isLoggedIn,
loginWithGitHubApp,
startGitHubDeviceFlowWithDefaults,
pollGitHubDeviceFlowWithSession,
completeGitHubDeviceLogin,
loginWithOAuthApp,
loginWithPersonalAccessToken,
logoutFromAccount,
Expand Down
21 changes: 4 additions & 17 deletions src/renderer/routes/Login.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type FC, useCallback, useEffect } from 'react';
import { type FC, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';

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

import { showWindow } from '../utils/comms';
import { rendererLogError } from '../utils/logger';

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

const { loginWithGitHubApp, isLoggedIn } = useAppContext();
const { isLoggedIn } = useAppContext();

useEffect(() => {
if (isLoggedIn) {
showWindow();
navigate('/', { replace: true });
}
}, [isLoggedIn]);

const loginUser = useCallback(async () => {
try {
await loginWithGitHubApp();
} catch (err) {
rendererLogError(
'loginWithGitHubApp',
'failed to login with GitHub',
err,
);
}
}, [loginWithGitHubApp]);
}, [isLoggedIn, navigate]);

return (
<Centered fullHeight={true}>
Expand All @@ -54,7 +41,7 @@ export const LoginRoute: FC = () => {
<Button
data-testid="login-github"
leadingVisual={MarkGithubIcon}
onClick={() => loginUser()}
onClick={() => navigate('/login-device-flow', { replace: true })}
variant="primary"
>
GitHub
Expand Down
96 changes: 96 additions & 0 deletions src/renderer/routes/LoginWithDeviceFlow.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import { renderWithAppContext } from '../__helpers__/test-utils';

import { LoginWithDeviceFlowRoute } from './LoginWithDeviceFlow';

const navigateMock = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => navigateMock,
}));

describe('renderer/routes/LoginWithDeviceFlow.tsx', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('should render and initialize device flow', async () => {
const startGitHubDeviceFlowMock = jest.fn().mockResolvedValueOnce({
hostname: 'github.com',
clientId: 'test-id',
deviceCode: 'device-code',
userCode: 'USER-1234',
verificationUri: 'https://github.com/login/device',
intervalSeconds: 5,
expiresAt: Date.now() + 900000,
});

renderWithAppContext(<LoginWithDeviceFlowRoute />, {
startGitHubDeviceFlow: startGitHubDeviceFlowMock,
});

expect(startGitHubDeviceFlowMock).toHaveBeenCalled();

await screen.findByText(/USER-1234/);
expect(screen.getByText(/github.com\/login\/device/)).toBeInTheDocument();
});

it('should copy user code to clipboard', async () => {
const startGitHubDeviceFlowMock = jest.fn().mockResolvedValueOnce({
hostname: 'github.com',
clientId: 'test-id',
deviceCode: 'device-code',
userCode: 'USER-1234',
verificationUri: 'https://github.com/login/device',
intervalSeconds: 5,
expiresAt: Date.now() + 900000,
});

renderWithAppContext(<LoginWithDeviceFlowRoute />, {
startGitHubDeviceFlow: startGitHubDeviceFlowMock,
});

await screen.findByText(/USER-1234/);

await userEvent.click(screen.getByLabelText('Copy device code'));

// We can't easily spy on navigator.clipboard in tests, but the button exists and works
expect(screen.getByLabelText('Copy device code')).toBeInTheDocument();
});

it('should handle device flow errors during initialization', async () => {
const startGitHubDeviceFlowMock = jest
.fn()
.mockRejectedValueOnce(new Error('Network error'));

renderWithAppContext(<LoginWithDeviceFlowRoute />, {
startGitHubDeviceFlow: startGitHubDeviceFlowMock,
});

await screen.findByText(/Failed to start authentication/);
});

it('should navigate back on cancel', async () => {
const startGitHubDeviceFlowMock = jest.fn().mockResolvedValueOnce({
hostname: 'github.com',
clientId: 'test-id',
deviceCode: 'device-code',
userCode: 'USER-1234',
verificationUri: 'https://github.com/login/device',
intervalSeconds: 5,
expiresAt: Date.now() + 900000,
});

renderWithAppContext(<LoginWithDeviceFlowRoute />, {
startGitHubDeviceFlow: startGitHubDeviceFlowMock,
});

await screen.findByText(/USER-1234/);

await userEvent.click(screen.getByText('Cancel'));

expect(navigateMock).toHaveBeenCalledWith(-1);
});
});
Loading
Loading