Skip to content
3 changes: 2 additions & 1 deletion src/commons/sagas/BackendSaga.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ export function* routerNavigate(path: string) {
const newBackendSagaOne = combineSagaHandlers({
[SessionActions.fetchAuth.type]: function* (action): any {
const { code, providerId: payloadProviderId } = action.payload;
const isVscode: boolean = yield select((state: OverallState) => state.vscode.isVscode);

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

const clientId = getClientId(providerId);
const redirectUrl = computeFrontendRedirectUri(providerId);
const redirectUrl = computeFrontendRedirectUri(providerId, isVscode);

const tokens: Tokens | null = yield call(postAuth, code, providerId, clientId, redirectUrl);
if (!tokens) {
Expand Down
12 changes: 9 additions & 3 deletions src/commons/sagas/LoginSaga.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { setUser } from '@sentry/browser';
import { call } from 'redux-saga/effects';
import { call, select } from 'redux-saga/effects';
import Messages, { sendToWebview } from 'src/features/vscode/messages';

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

const LoginSaga = combineSagaHandlers({
[SessionActions.login.type]: function* ({ payload: providerId }) {
const epUrl = computeEndpointUrl(providerId);
const isVscode = yield select(state => state.vscode.isVscode);
const epUrl = computeEndpointUrl(providerId, isVscode);
if (!epUrl) {
yield call(showWarningMessage, 'Could not log in; invalid provider name provided.');
return;
}
window.location.href = epUrl;
if (!isVscode) {
window.location.href = epUrl;
} else {
sendToWebview(Messages.LoginWithBrowser(epUrl));
}
},
[SessionActions.setUser.type]: function* (action) {
yield call(setUser, { id: action.payload.userId.toString() });
Expand Down
13 changes: 10 additions & 3 deletions src/commons/sagas/__tests__/BackendSaga.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,12 @@ const mockRouter = createMemoryRouter([
}
]);

const mockVscodeSlice = {
vscode: {
isVscode: false
}
};

const mockStates = {
router: mockRouter,
session: {
Expand All @@ -255,7 +261,8 @@ const mockStates = {
},
workspaces: {
assessment: { currentAssessment: mockAssessment.id }
}
},
...mockVscodeSlice
};

const okResp = { ok: true };
Expand Down Expand Up @@ -362,7 +369,7 @@ describe('Test FETCH_AUTH action', () => {
}
]
])
.withState({ session: mockTokens }) // need to mock tokens for updateLatestViewedCourse call
.withState({ session: mockTokens, ...mockVscodeSlice }) // need to mock tokens for updateLatestViewedCourse call
.call(postAuth, code, providerId, clientId, redirectUrl)
.put(SessionActions.setTokens(mockTokens))
.call(getUser, mockTokens)
Expand All @@ -377,7 +384,7 @@ describe('Test FETCH_AUTH action', () => {

test('when user is null', () => {
return expectSaga(BackendSaga)
.withState({ session: mockTokens }) // need to mock tokens for the selectTokens() call
.withState({ session: mockTokens, ...mockVscodeSlice }) // need to mock tokens for the selectTokens() call
.provide([
[call(postAuth, code, providerId, clientId, redirectUrl), mockTokens],
[
Expand Down
14 changes: 9 additions & 5 deletions src/commons/utils/AuthHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export enum AuthProviderType {
SAML_SSO = 'SAML'
}

export function computeEndpointUrl(providerId: string): string | undefined {
export function computeEndpointUrl(providerId: string, forVscode?: boolean): string | undefined {
const ep = Constants.authProviders.get(providerId);
if (!ep) {
return undefined;
Expand All @@ -15,10 +15,10 @@ export function computeEndpointUrl(providerId: string): string | undefined {
const epUrl = new URL(ep.endpoint);
switch (ep.type) {
case AuthProviderType.OAUTH2:
epUrl.searchParams.set('redirect_uri', computeFrontendRedirectUri(providerId)!);
epUrl.searchParams.set('redirect_uri', computeFrontendRedirectUri(providerId, forVscode)!);
break;
case AuthProviderType.CAS:
epUrl.searchParams.set('service', computeFrontendRedirectUri(providerId)!);
epUrl.searchParams.set('service', computeFrontendRedirectUri(providerId, forVscode)!);
break;
case AuthProviderType.SAML_SSO:
epUrl.searchParams.set('target_url', computeSamlRedirectUri(providerId)!);
Expand All @@ -31,13 +31,17 @@ export function computeEndpointUrl(providerId: string): string | undefined {
}
}

export function computeFrontendRedirectUri(providerId: string): string | undefined {
export function computeFrontendRedirectUri(
providerId: string,
forVscode?: boolean
): string | undefined {
const ep = Constants.authProviders.get(providerId);
if (!ep) {
return undefined;
}
const port = window.location.port === '' ? '' : `:${window.location.port}`;
const callback = `${window.location.protocol}//${window.location.hostname}${port}/login/callback${
const path = !forVscode ? '/login/callback' : '/login/vscode_callback';
const callback = `${window.location.protocol}//${window.location.hostname}${port}${path}${
ep.isDefault ? '' : '?provider=' + encodeURIComponent(providerId)
}`;
return callback;
Expand Down
3 changes: 2 additions & 1 deletion src/features/vscode/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ const Messages = createMessages({
questionId,
code
}),
Text: (code: string) => ({ code })
Text: (code: string) => ({ code }),
LoginWithBrowser: (route: string) => ({ route })
});

export default Messages;
Expand Down
73 changes: 68 additions & 5 deletions src/pages/login/LoginVscodeCallback.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,24 @@
import { Card, Classes, Elevation, NonIdealState, Spinner, SpinnerSize } from '@blueprintjs/core';
import {
Button,
ButtonGroup,
Card,
Classes,
Elevation,
H4,
Icon,
NonIdealState,
Spinner,
SpinnerSize
} from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
import classNames from 'classnames';
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { useLocation, useNavigate } from 'react-router';
import SessionActions from 'src/commons/application/actions/SessionActions';
import { useSession } from 'src/commons/utils/Hooks';
import { useTypedSelector } from 'src/commons/utils/Hooks';
import { parseQuery } from 'src/commons/utils/QueryHelper';
import classes from 'src/styles/Login.module.scss';

Expand All @@ -13,10 +27,36 @@
const dispatch = useDispatch();
const location = useLocation();
const { t } = useTranslation('login');

const { isLoggedIn } = useSession();
const {
code,
ticket,
provider: providerId,
'client-request-id': clientRequestId
} = parseQuery(location.search);
const isVscode = useTypedSelector(state => state.vscode.isVscode);
const { access_token: accessToken, refresh_token: refreshToken } = parseQuery(location.search);

// `code` parameter from OAuth2 redirect, `ticket` from CAS redirect (CAS untested for VS Code)
const authCode = code || ticket;

const launchVscode = () => {
window.location.href = `vscode://source-academy.source-academy/sso?code=${authCode}&client-request-id=${clientRequestId}`;
};

useEffect(() => {
if (authCode) {
if (!isVscode) {
launchVscode();
} else {
if (isLoggedIn) {
return;
}
// Fetch JWT tokens and user info from backend when auth provider code is present
dispatch(SessionActions.fetchAuth(authCode, providerId));
}
}

if (accessToken && refreshToken) {
dispatch(
SessionActions.setTokens({
Expand All @@ -27,10 +67,9 @@
dispatch(SessionActions.fetchUserAndCourse());
navigate('/welcome');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [isVscode]);

Check warning on line 70 in src/pages/login/LoginVscodeCallback.tsx

View workflow job for this annotation

GitHub Actions / lint (eslint)

React Hook useEffect has missing dependencies: 'accessToken', 'authCode', 'dispatch', 'isLoggedIn', 'launchVscode', 'navigate', 'providerId', and 'refreshToken'. Either include them or remove the dependency array

return (
return isVscode ? (
<div className={classNames(classes['Login'], Classes.DARK)}>
<Card elevation={Elevation.FOUR}>
<div>
Expand All @@ -41,6 +80,30 @@
</div>
</Card>
</div>
) : (
<div className={classNames(classes['Login'], Classes.DARK)}>
<Card elevation={Elevation.FOUR}>
<div>
<div className={classes['login-header']}>
<H4>
<Icon className={classes['login-icon']} icon={IconNames.LOG_IN} />
Sign in with SSO
</H4>
</div>
<p>
Click <b>Open link</b> on the dialog shown by your browser.
</p>
<p>If you don't see a dialog, click the button below.</p>
<div>
<ButtonGroup fill={true}>
<Button onClick={launchVscode} className={Classes.LARGE}>
Launch VS Code extension
</Button>
</ButtonGroup>
</div>
</div>
</Card>
</div>
);
};

Expand Down
Loading