Skip to content

Commit 72263d5

Browse files
heyzecRichDom2185
andauthored
feat: OAuth flow for VS Code (#3201)
* feat: OAuth flow with VS Code * fix: account for diff btw local mock and NUSNET * fix tests due to missing vscode slice unfortunately, mockStates does not have "satisfies OverallState" * chore: Reformat * remove `ticket` * fix: squelch warning (eslint) * refactor: move vscode baseurl to Constants.ts * fix: don't redir to /courses for vscode_callback --------- Co-authored-by: Richard Dominick <34370238+RichDom2185@users.noreply.github.com>
1 parent 5dfed3b commit 72263d5

File tree

8 files changed

+106
-22
lines changed

8 files changed

+106
-22
lines changed

src/commons/sagas/BackendSaga.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ export function* routerNavigate(path: string) {
121121
const newBackendSagaOne = combineSagaHandlers({
122122
[SessionActions.fetchAuth.type]: function* (action): any {
123123
const { code, providerId: payloadProviderId } = action.payload;
124+
const isVscode: boolean = yield select((state: OverallState) => state.vscode.isVscode);
124125

125126
const providerId = payloadProviderId || (getDefaultProvider() || [null])[0];
126127
if (!providerId) {
@@ -132,7 +133,7 @@ const newBackendSagaOne = combineSagaHandlers({
132133
}
133134

134135
const clientId = getClientId(providerId);
135-
const redirectUrl = computeFrontendRedirectUri(providerId);
136+
const redirectUrl = computeFrontendRedirectUri(providerId, isVscode);
136137

137138
const tokens: Tokens | null = yield call(postAuth, code, providerId, clientId, redirectUrl);
138139
if (!tokens) {

src/commons/sagas/LoginSaga.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { setUser } from '@sentry/browser';
2-
import { call } from 'redux-saga/effects';
2+
import { call, select } from 'redux-saga/effects';
3+
import Messages, { sendToWebview } from 'src/features/vscode/messages';
34

45
import CommonsActions from '../application/actions/CommonsActions';
56
import SessionActions from '../application/actions/SessionActions';
@@ -9,12 +10,17 @@ import { showWarningMessage } from '../utils/notifications/NotificationsHelper';
910

1011
const LoginSaga = combineSagaHandlers({
1112
[SessionActions.login.type]: function* ({ payload: providerId }) {
12-
const epUrl = computeEndpointUrl(providerId);
13+
const isVscode = yield select(state => state.vscode.isVscode);
14+
const epUrl = computeEndpointUrl(providerId, isVscode);
1315
if (!epUrl) {
1416
yield call(showWarningMessage, 'Could not log in; invalid provider name provided.');
1517
return;
1618
}
17-
window.location.href = epUrl;
19+
if (!isVscode) {
20+
window.location.href = epUrl;
21+
} else {
22+
sendToWebview(Messages.LoginWithBrowser(epUrl));
23+
}
1824
},
1925
[SessionActions.setUser.type]: function* (action) {
2026
yield call(setUser, { id: action.payload.userId.toString() });

src/commons/sagas/__tests__/BackendSaga.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,12 @@ const mockRouter = createMemoryRouter([
240240
}
241241
]);
242242

243+
const mockVscodeSlice = {
244+
vscode: {
245+
isVscode: false
246+
}
247+
};
248+
243249
const mockStates = {
244250
router: mockRouter,
245251
session: {
@@ -255,7 +261,8 @@ const mockStates = {
255261
},
256262
workspaces: {
257263
assessment: { currentAssessment: mockAssessment.id }
258-
}
264+
},
265+
...mockVscodeSlice
259266
};
260267

261268
const okResp = { ok: true };
@@ -362,7 +369,7 @@ describe('Test FETCH_AUTH action', () => {
362369
}
363370
]
364371
])
365-
.withState({ session: mockTokens }) // need to mock tokens for updateLatestViewedCourse call
372+
.withState({ session: mockTokens, ...mockVscodeSlice }) // need to mock tokens for updateLatestViewedCourse call
366373
.call(postAuth, code, providerId, clientId, redirectUrl)
367374
.put(SessionActions.setTokens(mockTokens))
368375
.call(getUser, mockTokens)
@@ -377,7 +384,7 @@ describe('Test FETCH_AUTH action', () => {
377384

378385
test('when user is null', () => {
379386
return expectSaga(BackendSaga)
380-
.withState({ session: mockTokens }) // need to mock tokens for the selectTokens() call
387+
.withState({ session: mockTokens, ...mockVscodeSlice }) // need to mock tokens for the selectTokens() call
381388
.provide([
382389
[call(postAuth, code, providerId, clientId, redirectUrl), mockTokens],
383390
[

src/commons/utils/AuthHelper.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export enum AuthProviderType {
66
SAML_SSO = 'SAML'
77
}
88

9-
export function computeEndpointUrl(providerId: string): string | undefined {
9+
export function computeEndpointUrl(providerId: string, forVscode?: boolean): string | undefined {
1010
const ep = Constants.authProviders.get(providerId);
1111
if (!ep) {
1212
return undefined;
@@ -15,10 +15,10 @@ export function computeEndpointUrl(providerId: string): string | undefined {
1515
const epUrl = new URL(ep.endpoint);
1616
switch (ep.type) {
1717
case AuthProviderType.OAUTH2:
18-
epUrl.searchParams.set('redirect_uri', computeFrontendRedirectUri(providerId)!);
18+
epUrl.searchParams.set('redirect_uri', computeFrontendRedirectUri(providerId, forVscode)!);
1919
break;
2020
case AuthProviderType.CAS:
21-
epUrl.searchParams.set('service', computeFrontendRedirectUri(providerId)!);
21+
epUrl.searchParams.set('service', computeFrontendRedirectUri(providerId, forVscode)!);
2222
break;
2323
case AuthProviderType.SAML_SSO:
2424
epUrl.searchParams.set('target_url', computeSamlRedirectUri(providerId)!);
@@ -31,13 +31,17 @@ export function computeEndpointUrl(providerId: string): string | undefined {
3131
}
3232
}
3333

34-
export function computeFrontendRedirectUri(providerId: string): string | undefined {
34+
export function computeFrontendRedirectUri(
35+
providerId: string,
36+
forVscode?: boolean
37+
): string | undefined {
3538
const ep = Constants.authProviders.get(providerId);
3639
if (!ep) {
3740
return undefined;
3841
}
3942
const port = window.location.port === '' ? '' : `:${window.location.port}`;
40-
const callback = `${window.location.protocol}//${window.location.hostname}${port}/login/callback${
43+
const path = !forVscode ? '/login/callback' : '/login/vscode_callback';
44+
const callback = `${window.location.protocol}//${window.location.hostname}${port}${path}${
4145
ep.isDefault ? '' : '?provider=' + encodeURIComponent(providerId)
4246
}`;
4347
return callback;

src/commons/utils/Constants.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,9 @@ export enum Links {
143143
aceHotkeys = 'https://github.com/ajaxorg/ace/wiki/Default-Keyboard-Shortcuts',
144144
sourceHotkeys = 'https://github.com/source-academy/frontend/wiki/Source-Academy-Keyboard-Shortcuts',
145145

146-
ecmaScript_2021 = 'https://262.ecma-international.org/12.0/'
146+
ecmaScript_2021 = 'https://262.ecma-international.org/12.0/',
147+
148+
vscode = 'vscode://source-academy.source-academy/sso'
147149
}
148150

149151
const Constants = {

src/features/vscode/messages.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,8 @@ const Messages = createMessages({
9898
AssessmentAnswer: (questionId: number, answer: string) => ({
9999
questionId,
100100
answer
101-
})
101+
}),
102+
LoginWithBrowser: (route: string) => ({ route })
102103
});
103104

104105
export default Messages;

src/pages/login/LoginVscodeCallback.tsx

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,25 @@
1-
import { Card, Classes, Elevation, NonIdealState, Spinner, SpinnerSize } from '@blueprintjs/core';
1+
import {
2+
Button,
3+
ButtonGroup,
4+
Card,
5+
Classes,
6+
Elevation,
7+
H4,
8+
Icon,
9+
NonIdealState,
10+
Spinner,
11+
SpinnerSize
12+
} from '@blueprintjs/core';
13+
import { IconNames } from '@blueprintjs/icons';
214
import classNames from 'classnames';
315
import React, { useEffect } from 'react';
416
import { useTranslation } from 'react-i18next';
517
import { useDispatch } from 'react-redux';
618
import { useLocation, useNavigate } from 'react-router';
719
import SessionActions from 'src/commons/application/actions/SessionActions';
20+
import { Links } from 'src/commons/utils/Constants';
21+
import { useSession } from 'src/commons/utils/Hooks';
22+
import { useTypedSelector } from 'src/commons/utils/Hooks';
823
import { parseQuery } from 'src/commons/utils/QueryHelper';
924
import classes from 'src/styles/Login.module.scss';
1025

@@ -13,10 +28,32 @@ const LoginVscodeCallback: React.FC = () => {
1328
const dispatch = useDispatch();
1429
const location = useLocation();
1530
const { t } = useTranslation('login');
16-
31+
const { isLoggedIn } = useSession();
32+
const {
33+
code,
34+
provider: providerId,
35+
'client-request-id': clientRequestId
36+
} = parseQuery(location.search);
37+
const isVscode = useTypedSelector(state => state.vscode.isVscode);
1738
const { access_token: accessToken, refresh_token: refreshToken } = parseQuery(location.search);
1839

40+
const launchVscode = () => {
41+
window.location.href = `${Links.vscode}/sso?code=${code}&client-request-id=${clientRequestId}`;
42+
};
43+
1944
useEffect(() => {
45+
if (code) {
46+
if (!isVscode) {
47+
launchVscode();
48+
} else {
49+
if (isLoggedIn) {
50+
return;
51+
}
52+
// Fetch JWT tokens and user info from backend when auth provider code is present
53+
dispatch(SessionActions.fetchAuth(code, providerId));
54+
}
55+
}
56+
2057
if (accessToken && refreshToken) {
2158
dispatch(
2259
SessionActions.setTokens({
@@ -27,10 +64,11 @@ const LoginVscodeCallback: React.FC = () => {
2764
dispatch(SessionActions.fetchUserAndCourse());
2865
navigate('/welcome');
2966
}
67+
// Only isVscode is expected to change in the lifecycle
3068
// eslint-disable-next-line react-hooks/exhaustive-deps
31-
}, []);
69+
}, [isVscode]);
3270

33-
return (
71+
return isVscode ? (
3472
<div className={classNames(classes['Login'], Classes.DARK)}>
3573
<Card elevation={Elevation.FOUR}>
3674
<div>
@@ -41,6 +79,30 @@ const LoginVscodeCallback: React.FC = () => {
4179
</div>
4280
</Card>
4381
</div>
82+
) : (
83+
<div className={classNames(classes['Login'], Classes.DARK)}>
84+
<Card elevation={Elevation.FOUR}>
85+
<div>
86+
<div className={classes['login-header']}>
87+
<H4>
88+
<Icon className={classes['login-icon']} icon={IconNames.LOG_IN} />
89+
Sign in with SSO
90+
</H4>
91+
</div>
92+
<p>
93+
Click <b>Open link</b> on the dialog shown by your browser.
94+
</p>
95+
<p>If you don't see a dialog, click the button below.</p>
96+
<div>
97+
<ButtonGroup fill={true}>
98+
<Button onClick={launchVscode} className={Classes.LARGE}>
99+
Launch VS Code extension
100+
</Button>
101+
</ButtonGroup>
102+
</div>
103+
</div>
104+
</Card>
105+
</div>
44106
);
45107
};
46108

src/routes/routerConfig.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -129,10 +129,11 @@ export const getFullAcademyRouterConfig = ({
129129
{
130130
path: 'login',
131131
lazy: Login,
132-
children: [
133-
{ path: 'callback', lazy: LoginCallback },
134-
{ path: 'vscode_callback', lazy: LoginVscodeCallback }
135-
]
132+
children: [{ path: 'callback', lazy: LoginCallback }]
133+
},
134+
{
135+
path: 'login',
136+
children: [{ path: 'vscode_callback', lazy: LoginVscodeCallback }]
136137
},
137138
{ path: 'welcome', lazy: Welcome, loader: welcomeLoader },
138139
{ path: 'courses', element: <Navigate to="/" /> },

0 commit comments

Comments
 (0)