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

Desktop app: Authenticate with OAuth #98345

Merged
merged 13 commits into from
Feb 12, 2025
14 changes: 14 additions & 0 deletions client/login/controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import config from '@automattic/calypso-config';
import page from '@automattic/calypso-router';
import { getUrlParts } from '@automattic/calypso-url';
import { isGravPoweredOAuth2Client, isWooOAuth2Client } from 'calypso/lib/oauth2-clients';
import { DesktopLoginStart, DesktopLoginFinalize } from 'calypso/login/desktop-login';
import { SOCIAL_HANDOFF_CONNECT_ACCOUNT } from 'calypso/state/action-types';
import { isUserLoggedIn, getCurrentUserLocale } from 'calypso/state/current-user/selectors';
import { fetchOAuth2ClientData } from 'calypso/state/oauth2-clients/actions';
Expand Down Expand Up @@ -135,6 +136,19 @@ export async function login( context, next ) {
next();
}

export function desktopLogin( context, next ) {
context.primary = <DesktopLoginStart />;
next();
}

export function desktopLoginFinalize( context, next ) {
const { hash } = context;
context.primary = (
<DesktopLoginFinalize error={ hash?.error } accessToken={ hash?.access_token } />
);
next();
}

export async function magicLogin( context, next ) {
const {
path,
Expand Down
72 changes: 72 additions & 0 deletions client/login/desktop-login/finalize.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import debugFactory from 'debug';
import { useEffect, useState } from 'react';
import DesktopLoginStart from 'calypso/login/desktop-login/start';
import WpcomLoginForm from 'calypso/signup/wpcom-login-form';

const debug = debugFactory( 'calypso:desktop' );

interface Props {
accessToken: string;
error?: string;
}

/**
* Final page of the login flow of the WordPress.com Desktop app.
*
* When the user has authenticated in their browser, they get redirected back to
* the desktop app and end up here, with the access token passed as a prop.
*
* We then use that access token to submit the login form, which will set the cookie,
* and thus log the user in.
*/
export default function DesktopLoginFinalize( props: Props ) {
const { accessToken } = props;
const [ username, setUsername ] = useState< string >();
const [ error, setError ] = useState< string | undefined >(
props.error ?? ! accessToken ? 'Access token is missing' : undefined
);

if ( error ) {
debug( error );
}

useEffect( () => {
if ( ! error && accessToken && ! username ) {
debug( 'Retrieving username from the API' );
getUsername( accessToken )
.then( setUsername )
.catch( () => setError( 'Failed to retrieve username' ) );
}
}, [ error, accessToken, username ] );

if ( error ) {
// Something went wrong, we render the desktop login start page,
// which will display an error.
return <DesktopLoginStart error={ error } />;
}

if ( ! username ) {
// We haven't retrieved the username yet.
return undefined;
}

return (
<WpcomLoginForm
log={ username }
authorization={ 'Bearer ' + accessToken }
redirectTo={ window.location.href }
/>
);
}

async function getUsername( accessToken: string ): Promise< string > {
const response = await fetch( 'https://public-api.wordpress.com/rest/v1/me', {
headers: {
Authorization: `Bearer ${ accessToken }`,
},
} );
if ( ! response.ok ) {
throw new Error( `Failed to retrieve username: ${ response.status }` );
}
return ( await response.json() ).username;
}
5 changes: 5 additions & 0 deletions client/login/desktop-login/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import './style.scss';
import DesktopLoginFinalize from './finalize';
import DesktopLoginStart from './start';

export { DesktopLoginStart, DesktopLoginFinalize };
44 changes: 44 additions & 0 deletions client/login/desktop-login/start.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Button } from '@wordpress/components';
import { useTranslate } from 'i18n-calypso';
import { useState } from 'react';
import FormattedHeader from 'calypso/components/formatted-header';
import Main from 'calypso/components/main';
import Notice from 'calypso/components/notice';

// The desktop app will intercept this URL and start the login in the user's external browser.
const loginUrl = '/desktop-start-login';

interface Props {
error?: string;
}

/**
* Initial page of the login flow of the WordPress.com Desktop app.
*
* Renders a button that when clicked sends the user to their browser (outside the desktop app),
* so that they can log in to WordPress.com.
*/
export default function DesktopLoginStart( props: Props ) {
const translate = useTranslate();
const [ error, setError ] = useState< string | undefined >( props.error );

return (
<Main className="desktop-login">
<div className="desktop-login__content">
{ error ? (
<Notice status="is-error" onDismissClick={ () => setError( undefined ) }>
{ translate( 'We were not able to log you in. Please try again.' ) }
</Notice>
) : undefined }
<FormattedHeader
headerText={ translate( 'Log in' ) }
subHeaderText={ translate( 'Authorize with WordPress.com to get started' ) }
brandFont
/>
<Button variant="primary" href={ loginUrl } onClick={ () => setError( undefined ) }>
{ translate( 'Log in with WordPress.com' ) }
</Button>
</div>
</Main>
);
}
16 changes: 16 additions & 0 deletions client/login/desktop-login/style.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
.desktop-login.main {
margin: auto;
max-width: 480px;
}

.desktop-login__content {
align-items: center;
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;

& > * {
margin-block-end: 32px;
}
}
27 changes: 27 additions & 0 deletions client/login/index.web.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
redirectJetpack,
redirectDefaultLocale,
redirectLostPassword,
desktopLogin,
desktopLoginFinalize,
} from './controller';
import redirectLoggedIn from './redirect-logged-in';
import { setShouldServerSideRenderLogin, ssrSetupLocaleLogin, setMetaTags } from './ssr';
Expand Down Expand Up @@ -66,6 +68,31 @@ const makeLoggedOutLayout = makeLayoutMiddleware( ReduxWrappedLayout );
export default ( router ) => {
const lang = getLanguageRouteParam();

// In development environments, the desktop app can be launched in a way in which it does not bundle calypso,
// but instead uses a calypso instance that is running outside the desktop app.
// In such a scenario, `config.isEnabled( 'desktop' )` returns false, but we still want the route to be available.
// For this reason, we always enable the desktop login routes in development environments.
if ( config.isEnabled( 'desktop' ) || config( 'env_id' ) === 'development' ) {
router(
[ `/log-in/desktop/${ lang }` ],
redirectLoggedIn,
setLocaleMiddleware(),
setMetaTags,
setSectionMiddleware( { ...LOGIN_SECTION_DEFINITION, isomorphic: false } ),
desktopLogin,
makeLoggedOutLayout
);
router(
[ `/log-in/desktop/finalize` ],
redirectLoggedIn,
setLocaleMiddleware(),
setMetaTags,
setSectionMiddleware( { ...LOGIN_SECTION_DEFINITION, isomorphic: false } ),
desktopLoginFinalize,
makeLoggedOutLayout
);
}

if ( config.isEnabled( 'login/magic-login' ) ) {
router(
[ `/log-in/link/use/${ lang }`, `/log-in/jetpack/link/use/${ lang }` ],
Expand Down
3 changes: 3 additions & 0 deletions desktop/app/app-handlers/protocol/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Protocol handler

Registers the app as a protocol handler, so that links that the user clicks in their browser that have the form `wpdesktop://*` are opened in the app.
16 changes: 16 additions & 0 deletions desktop/app/app-handlers/protocol/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
const path = require( 'node:path' );
const { app } = require( 'electron' );
const Config = require( '../../lib/config' );

module.exports = function () {
const protocol = Config.protocol;
if ( process.defaultApp ) {
if ( process.argv.length >= 2 ) {
app.setAsDefaultProtocolClient( protocol, process.execPath, [
path.resolve( process.argv[ 1 ] ),
] );
}
} else {
app.setAsDefaultProtocolClient( protocol );
}
};
9 changes: 8 additions & 1 deletion desktop/app/app.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
const { app } = require( 'electron' );
let log;

module.exports = function () {
if ( ! app.requestSingleInstanceLock() ) {
app.quit();
return;
}

require( './env' ); // Must come first to setup the environment
log = require( './lib/logger' )( 'desktop:index' );

log.info( 'Starting app handlers' );

// Stuff that can run before the main window
// Stuff that runs before the main window.
require( './app-handlers/logging' )();
require( './app-handlers/crash-reporting' )();
require( './app-handlers/updater' )();
require( './app-handlers/protocol' )();
require( './app-handlers/preferences' )();
require( './app-handlers/secrets' )();
require( './app-handlers/printer' )();
Expand Down
1 change: 0 additions & 1 deletion desktop/app/lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

The following libraries are available:

- [App Instance](app-instance/README.md)
- [App Quit](app-quit/README.md)
- [Config](config/README.md)
- [Cookie Auth](cookie-auth/README.md)
Expand Down
10 changes: 0 additions & 10 deletions desktop/app/lib/app-instance/README.md

This file was deleted.

26 changes: 0 additions & 26 deletions desktop/app/lib/app-instance/index.js

This file was deleted.

2 changes: 2 additions & 0 deletions desktop/app/lib/config/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ config.description = 'WordPress Desktop';
config.version = pkg.version;
config.author = pkg.author;

config.protocol = 'wpdesktop';

config.loginURL = function () {
return this.baseURL() + 'log-in';
};
Expand Down
7 changes: 3 additions & 4 deletions desktop/app/mainWindow/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
const { app, BrowserWindow, BrowserView, ipcMain: ipc } = require( 'electron' );
const appInstance = require( '../lib/app-instance' );
const { getPath } = require( '../lib/assets' );
const Config = require( '../lib/config' );
const log = require( '../lib/logger' )( 'desktop:runapp' );
Expand Down Expand Up @@ -157,6 +156,8 @@ function showAppWindow() {
} );

const appWindow = { view: mainView, window: mainWindow };
require( '../window-handlers/login' )( appWindow );
require( '../window-handlers/incoming-urls' )( appWindow );
require( '../window-handlers/failed-to-load' )( appWindow );
require( '../window-handlers/login-status' )( appWindow );
require( '../window-handlers/notifications' )( appWindow );
Expand All @@ -174,7 +175,5 @@ function showAppWindow() {
}

module.exports = function () {
if ( appInstance.isSingleInstance() ) {
app.on( 'ready', showAppWindow );
}
app.on( 'ready', showAppWindow );
};
3 changes: 3 additions & 0 deletions desktop/app/window-handlers/incoming-urls/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Incoming URLs

Handle incoming URLs.
47 changes: 47 additions & 0 deletions desktop/app/window-handlers/incoming-urls/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
const { app } = require( 'electron' );
const Config = require( '../../lib/config' );

module.exports = function ( { view, mainWindow } ) {
// Mac.
app.on( 'open-url', ( event, url ) => handleUrl( view, url ) );

// Windows and Linux. For more information see:
// https://www.electronjs.org/docs/latest/tutorial/launch-app-from-url-in-another-app#windows-and-linux-code
app.on( 'second-instance', ( event, commandLine ) => {
if ( mainWindow ) {
if ( mainWindow.isMinimized() ) {
mainWindow.restore();
}
mainWindow.focus();
}
// The commandLine is an array of strings in which the last element is the url.
handleUrl( view, commandLine.pop() );
} );
};

function handleUrl( view, url ) {
if ( ! url ) {
return;
}
const u = new URL( url );

// It should not be possible that the protocol is not Config.protocol, but you never know.
if ( u.protocol !== `${ Config.protocol }:` ) {
return;
}

// We only care about login URLs, all other URLs are ignored for now.
// We're comparing with `u.host` here because the URL has the form wpdesktop://token#*,
// so `auth` is actually the host, not the pathname.
if ( u.host !== 'token' ) {
return;
}

// If the hash is not present, something must have gone wrong, so we redirect back to the login page.
if ( ! u.hash ) {
void view.webContents.loadURL( Config.loginURL() );
return;
}

void view.webContents.loadURL( Config.loginURL() + `/finalize${ u.hash }` );
}
3 changes: 3 additions & 0 deletions desktop/app/window-handlers/login/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Login

Listens for the `will-navigate` to `/desktop-start-login` and opens the authentication URL in the external browser.
Loading
Loading