Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(auth): use system browser for GitHub SSO / OAuth authentication #1781

Merged
merged 17 commits into from
Jan 29, 2025
Merged
Show file tree
Hide file tree
Changes from 8 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
4 changes: 2 additions & 2 deletions config/webpack.config.renderer.base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ const configuration: webpack.Configuration = {
plugins: [
// Development Keys - See README.md
setchy marked this conversation as resolved.
Show resolved Hide resolved
new webpack.EnvironmentPlugin({
OAUTH_CLIENT_ID: '3fef4433a29c6ad8f22c',
OAUTH_CLIENT_SECRET: '9670de733096c15322183ff17ed0fc8704050379',
OAUTH_CLIENT_ID: 'Ov23liQIkFs5ehQLNzHF',
OAUTH_CLIENT_SECRET: '404b80632292e18419dbd2a6ed25976856e95255',
}),

// Extract CSS into a separate file
Expand Down
37 changes: 33 additions & 4 deletions src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { menubar } from 'menubar';

import { APPLICATION } from '../shared/constants';
import { namespacedEvent } from '../shared/events';
import { logError } from '../shared/logger';
import { isMacOS, isWindows } from '../shared/platform';
import { onFirstRunMaybe } from './first-run';
import { TrayIcons } from './icons';
Expand Down Expand Up @@ -37,11 +38,14 @@ const mb = menubar({
const menuBuilder = new MenuBuilder(mb);
const contextMenu = menuBuilder.buildMenu();

/**
* Electron Auto Updater only supports macOS and Windows
* https://github.com/electron/update-electron-app
*/
// Register your app as the handler for a custom protocol
app.setAsDefaultProtocolClient('gitify');

if (isMacOS() || isWindows()) {
/**
* Electron Auto Updater only supports macOS and Windows
* https://github.com/electron/update-electron-app
*/
const updater = new Updater(mb, menuBuilder);
updater.initialize();
}
Expand Down Expand Up @@ -186,3 +190,28 @@ app.whenReady().then(async () => {
app.setLoginItemSettings(settings);
});
});

// Handle gitify:// custom protocol URL events for OAuth 2.0 callback
app.on('open-url', (event, url) => {
event.preventDefault();

const link = new URL(url);

const type = link.hostname;
const code = link.searchParams.get('code');

if (code && (type === 'auth' || type === 'oauth')) {
mb.window.webContents.send(namespacedEvent('auth-code'), type, code);
}

const error = link.searchParams.get('error');
const errorDescription = link.searchParams.get('error_description');

if (error) {
logError(
'main:open-url',
`Error during OAuth 2.0 callback ${error}`,
new Error(errorDescription),
);
}
});
1 change: 1 addition & 0 deletions src/renderer/utils/auth/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface LoginPersonalAccessTokenOptions {
}

export interface AuthResponse {
authType: AuthMethod;
authCode: AuthCode;
authOptions: LoginOAuthAppOptions;
}
Expand Down
79 changes: 46 additions & 33 deletions src/renderer/utils/auth/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import remote from '@electron/remote';
import axios from 'axios';
import type { AxiosPromise, AxiosResponse } from 'axios';
import { ipcRenderer } from 'electron';
import nock from 'nock';
import {
mockAuth,
Expand All @@ -12,67 +12,80 @@ import type {
AuthCode,
AuthState,
ClientID,
ClientSecret,
Hostname,
Token,
} from '../../types';
import * as comms from '../../utils/comms';
import * as apiRequests from '../api/request';
import type { AuthMethod } from './types';
import * as auth from './utils';
import { getNewOAuthAppURL, getNewTokenURL } from './utils';

const browserWindow = new remote.BrowserWindow();

describe('renderer/utils/auth/utils.ts', () => {
describe('authGitHub', () => {
const loadURLMock = jest.spyOn(browserWindow, 'loadURL');
const openExternalLinkMock = jest
.spyOn(comms, 'openExternalLink')
.mockImplementation();

afterEach(() => {
jest.clearAllMocks();
});

it('should call authGitHub - success', async () => {
// Casting to jest.Mock avoids Typescript errors, where the spy is expected to match all the original
// function's typing. I might fix all that if the return type of this was actually used, or if I was
// writing this test for a new feature. Since I'm just upgrading Jest, jest.Mock is a nice escape hatch
(
jest.spyOn(browserWindow.webContents, 'on') as jest.Mock
).mockImplementation((event, callback): void => {
if (event === 'will-redirect') {
const event = new Event('will-redirect');
callback(event, 'https://github.com/?code=123-456');
it('should call authGitHub - auth flow', async () => {
const mockIpcRendererOn = (
jest.spyOn(ipcRenderer, 'on') as jest.Mock
).mockImplementation((event, callback) => {
if (event === 'gitify:auth-code') {
callback(null, 'auth', '123-456');
}
});

const res = await auth.authGitHub();

expect(res.authCode).toBe('123-456');

expect(
browserWindow.webContents.session.clearStorageData,
).toHaveBeenCalledTimes(1);

expect(loadURLMock).toHaveBeenCalledTimes(1);
expect(loadURLMock).toHaveBeenCalledWith(
expect(openExternalLinkMock).toHaveBeenCalledTimes(1);
expect(openExternalLinkMock).toHaveBeenCalledWith(
'https://github.com/login/oauth/authorize?client_id=FAKE_CLIENT_ID_123&scope=read%3Auser%2Cnotifications%2Crepo',
);

expect(browserWindow.destroy).toHaveBeenCalledTimes(1);
expect(mockIpcRendererOn).toHaveBeenCalledTimes(1);
expect(mockIpcRendererOn).toHaveBeenCalledWith(
'gitify:auth-code',
expect.any(Function),
);

expect(res.authType).toBe('GitHub App');
expect(res.authCode).toBe('123-456');
});

it('should call authGitHub - failure', async () => {
(
jest.spyOn(browserWindow.webContents, 'on') as jest.Mock
).mockImplementation((event, callback): void => {
if (event === 'will-redirect') {
const event = new Event('will-redirect');
callback(event, 'https://www.github.com/?error=Oops');
it('should call authGitHub - oauth flow', async () => {
const mockIpcRendererOn = (
jest.spyOn(ipcRenderer, 'on') as jest.Mock
).mockImplementation((event, callback) => {
if (event === 'gitify:auth-code') {
callback(null, 'oauth', '123-456');
}
});

await expect(async () => await auth.authGitHub()).rejects.toEqual(
"Oops! Something went wrong and we couldn't log you in using GitHub. Please try again.",
const res = await auth.authGitHub({
clientId: 'BYO_CLIENT_ID' as ClientID,
clientSecret: 'BYO_CLIENT_SECRET' as ClientSecret,
hostname: 'my.git.com' as Hostname,
});

expect(openExternalLinkMock).toHaveBeenCalledTimes(1);
expect(openExternalLinkMock).toHaveBeenCalledWith(
'https://my.git.com/login/oauth/authorize?client_id=BYO_CLIENT_ID&scope=read%3Auser%2Cnotifications%2Crepo',
);
expect(loadURLMock).toHaveBeenCalledTimes(1);

expect(mockIpcRendererOn).toHaveBeenCalledTimes(1);
expect(mockIpcRendererOn).toHaveBeenCalledWith(
'gitify:auth-code',
expect.any(Function),
);

expect(res.authType).toBe('OAuth App');
expect(res.authCode).toBe('123-456');
});
});

Expand Down
75 changes: 16 additions & 59 deletions src/renderer/utils/auth/utils.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { BrowserWindow } from '@electron/remote';
import { format } from 'date-fns';
import semver from 'semver';

import { ipcRenderer } from 'electron';
import { APPLICATION } from '../../../shared/constants';
import { namespacedEvent } from '../../../shared/events';
import { logError, logWarn } from '../../../shared/logger';
import type {
Account,
Expand All @@ -17,78 +18,34 @@ import type {
import type { UserDetails } from '../../typesGitHub';
import { getAuthenticatedUser } from '../api/client';
import { apiRequest } from '../api/request';
import { openExternalLink } from '../comms';
import { Constants } from '../constants';
import { getPlatformFromHostname } from '../helpers';
import type { AuthMethod, AuthResponse, AuthTokenResponse } from './types';

// TODO - Refactor our OAuth2 flow to use system browser and local app gitify://callback - see #485 #561 #654
export function authGitHub(
authOptions = Constants.DEFAULT_AUTH_OPTIONS,
): Promise<AuthResponse> {
return new Promise((resolve, reject) => {
// Build the OAuth consent page URL
const authWindow = new BrowserWindow({
width: 548,
height: 736,
show: true,
});

return new Promise((resolve) => {
const authUrl = new URL(`https://${authOptions.hostname}`);
authUrl.pathname = '/login/oauth/authorize';
authUrl.searchParams.append('client_id', authOptions.clientId);
authUrl.searchParams.append('scope', Constants.AUTH_SCOPE.toString());

const session = authWindow.webContents.session;
session.clearStorageData();

authWindow.loadURL(authUrl.toString());

const handleCallback = (url: Link) => {
const raw_code = /code=([^&]*)/.exec(url) || null;
const authCode =
raw_code && raw_code.length > 1 ? (raw_code[1] as AuthCode) : null;
const error = /\?error=(.+)$/.exec(url);
if (authCode || error) {
// Close the browser if code found or error
authWindow.destroy();
}
// If there is a code, proceed to get token from github
if (authCode) {
resolve({ authCode, authOptions });
} else if (error) {
reject(
"Oops! Something went wrong and we couldn't " +
'log you in using GitHub. Please try again.',
);
}
openExternalLink(authUrl.toString() as Link);

const handleCallback = (authType: AuthMethod, authCode: AuthCode) => {
resolve({ authType, authCode, authOptions });
};

// If "Done" button is pressed, hide "Loading"
authWindow.on('close', () => {
authWindow.destroy();
});

authWindow.webContents.on(
'did-fail-load',
(_event, _errorCode, _errorDescription, validatedURL) => {
if (validatedURL.includes(authOptions.hostname)) {
authWindow.destroy();
reject(
`Invalid Hostname. Could not load https://${authOptions.hostname}/.`,
);
}
ipcRenderer.on(
namespacedEvent('auth-code'),
(_, authType: 'auth' | 'oauth', authCode: AuthCode) => {
const type: AuthMethod =
authType === 'auth' ? 'GitHub App' : 'OAuth App';
handleCallback(type, authCode);
},
);

authWindow.webContents.on('will-redirect', (event, url) => {
event.preventDefault();
handleCallback(url as Link);
});

authWindow.webContents.on('will-navigate', (event, url) => {
event.preventDefault();
handleCallback(url as Link);
});
});
}

Expand Down Expand Up @@ -249,11 +206,11 @@ export function getNewOAuthAppURL(hostname: Hostname): Link {
);
newOAuthAppURL.searchParams.append(
'oauth_application[url]',
'https://www.gitify.io',
'https://gitify.io',
);
newOAuthAppURL.searchParams.append(
'oauth_application[callback_url]',
'https://www.gitify.io/callback',
'gitify://oauth',
);

return newOAuthAppURL.toString() as Link;
Expand Down
Loading