Skip to content

Commit 3dd92c2

Browse files
committed
feat: OAuth flow with VS Code
1 parent 0d6f3c0 commit 3dd92c2

File tree

5 files changed

+87
-15
lines changed

5 files changed

+87
-15
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 = yield select(state => 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/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/features/vscode/messages.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ const Messages = createMessages({
1616
questionId,
1717
code
1818
}),
19-
Text: (code: string) => ({ code })
19+
Text: (code: string) => ({ code }),
20+
LoginWithBrowser: (route: string) => ({
21+
route
22+
})
2023
});
2124

2225
export default Messages;

src/pages/login/LoginVscodeCallback.tsx

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,24 @@
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 { useSession } from 'src/commons/utils/Hooks';
21+
import { useTypedSelector } from 'src/commons/utils/Hooks';
822
import { parseQuery } from 'src/commons/utils/QueryHelper';
923
import classes from 'src/styles/Login.module.scss';
1024

@@ -13,10 +27,31 @@ const LoginVscodeCallback: React.FC = () => {
1327
const dispatch = useDispatch();
1428
const location = useLocation();
1529
const { t } = useTranslation('login');
16-
30+
const { isLoggedIn } = useSession();
31+
const { code, ticket, provider: providerId } = parseQuery(location.search);
32+
const isVscode = useTypedSelector(state => state.vscode.isVscode);
1733
const { access_token: accessToken, refresh_token: refreshToken } = parseQuery(location.search);
1834

35+
// `code` parameter from OAuth2 redirect, `ticket` from CAS redirect (CAS untested for VS Code)
36+
const authCode = code || ticket;
37+
38+
const launchVscode = () => {
39+
window.location.href = `vscode://source-academy.source-academy/sso?code=${authCode}&provider=${providerId}`;
40+
};
41+
1942
useEffect(() => {
43+
if (authCode) {
44+
if (!isVscode) {
45+
launchVscode();
46+
} else {
47+
if (isLoggedIn) {
48+
return;
49+
}
50+
// Fetch JWT tokens and user info from backend when auth provider code is present
51+
dispatch(SessionActions.fetchAuth(authCode, providerId));
52+
}
53+
}
54+
2055
if (accessToken && refreshToken) {
2156
dispatch(
2257
SessionActions.setTokens({
@@ -27,10 +62,9 @@ const LoginVscodeCallback: React.FC = () => {
2762
dispatch(SessionActions.fetchUserAndCourse());
2863
navigate('/welcome');
2964
}
30-
// eslint-disable-next-line react-hooks/exhaustive-deps
31-
}, []);
65+
}, [isVscode]);
3266

33-
return (
67+
return isVscode ? (
3468
<div className={classNames(classes['Login'], Classes.DARK)}>
3569
<Card elevation={Elevation.FOUR}>
3670
<div>
@@ -41,6 +75,30 @@ const LoginVscodeCallback: React.FC = () => {
4175
</div>
4276
</Card>
4377
</div>
78+
) : (
79+
<div className={classNames(classes['Login'], Classes.DARK)}>
80+
<Card elevation={Elevation.FOUR}>
81+
<div>
82+
<div className={classes['login-header']}>
83+
<H4>
84+
<Icon className={classes['login-icon']} icon={IconNames.LOG_IN} />
85+
Sign in with SSO
86+
</H4>
87+
</div>
88+
<p>
89+
Click <b>Open link</b> on the dialog shown by your browser.
90+
</p>
91+
<p>If you don't see a dialog, click the button below.</p>
92+
<div>
93+
<ButtonGroup fill={true}>
94+
<Button onClick={launchVscode} className={Classes.LARGE}>
95+
Launch VS Code extension
96+
</Button>
97+
</ButtonGroup>
98+
</div>
99+
</div>
100+
</Card>
101+
</div>
44102
);
45103
};
46104

0 commit comments

Comments
 (0)