diff --git a/src/commons/sagas/BackendSaga.ts b/src/commons/sagas/BackendSaga.ts index abca6e3d09..19f0dc4ad3 100644 --- a/src/commons/sagas/BackendSaga.ts +++ b/src/commons/sagas/BackendSaga.ts @@ -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) { @@ -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) { diff --git a/src/commons/sagas/LoginSaga.ts b/src/commons/sagas/LoginSaga.ts index 05678a5c8d..655d88a5c1 100644 --- a/src/commons/sagas/LoginSaga.ts +++ b/src/commons/sagas/LoginSaga.ts @@ -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'; @@ -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() }); diff --git a/src/commons/sagas/__tests__/BackendSaga.ts b/src/commons/sagas/__tests__/BackendSaga.ts index a92f0fde07..bc788c728d 100644 --- a/src/commons/sagas/__tests__/BackendSaga.ts +++ b/src/commons/sagas/__tests__/BackendSaga.ts @@ -240,6 +240,12 @@ const mockRouter = createMemoryRouter([ } ]); +const mockVscodeSlice = { + vscode: { + isVscode: false + } +}; + const mockStates = { router: mockRouter, session: { @@ -255,7 +261,8 @@ const mockStates = { }, workspaces: { assessment: { currentAssessment: mockAssessment.id } - } + }, + ...mockVscodeSlice }; const okResp = { ok: true }; @@ -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) @@ -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], [ diff --git a/src/commons/utils/AuthHelper.ts b/src/commons/utils/AuthHelper.ts index 80aa030d3f..77248f78c1 100644 --- a/src/commons/utils/AuthHelper.ts +++ b/src/commons/utils/AuthHelper.ts @@ -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; @@ -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)!); @@ -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; diff --git a/src/commons/utils/Constants.ts b/src/commons/utils/Constants.ts index 28a5555c3a..e5cc2c8c22 100644 --- a/src/commons/utils/Constants.ts +++ b/src/commons/utils/Constants.ts @@ -143,7 +143,9 @@ export enum Links { aceHotkeys = 'https://github.com/ajaxorg/ace/wiki/Default-Keyboard-Shortcuts', sourceHotkeys = 'https://github.com/source-academy/frontend/wiki/Source-Academy-Keyboard-Shortcuts', - ecmaScript_2021 = 'https://262.ecma-international.org/12.0/' + ecmaScript_2021 = 'https://262.ecma-international.org/12.0/', + + vscode = 'vscode://source-academy.source-academy/sso' } const Constants = { diff --git a/src/features/vscode/messages.ts b/src/features/vscode/messages.ts index 3c8004b171..c18a129610 100644 --- a/src/features/vscode/messages.ts +++ b/src/features/vscode/messages.ts @@ -98,7 +98,8 @@ const Messages = createMessages({ AssessmentAnswer: (questionId: number, answer: string) => ({ questionId, answer - }) + }), + LoginWithBrowser: (route: string) => ({ route }) }); export default Messages; diff --git a/src/pages/login/LoginVscodeCallback.tsx b/src/pages/login/LoginVscodeCallback.tsx index 145d8a3793..041945364d 100644 --- a/src/pages/login/LoginVscodeCallback.tsx +++ b/src/pages/login/LoginVscodeCallback.tsx @@ -1,10 +1,25 @@ -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 { Links } from 'src/commons/utils/Constants'; +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'; @@ -13,10 +28,32 @@ const LoginVscodeCallback: React.FC = () => { const dispatch = useDispatch(); const location = useLocation(); const { t } = useTranslation('login'); - + const { isLoggedIn } = useSession(); + const { + code, + 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); + const launchVscode = () => { + window.location.href = `${Links.vscode}/sso?code=${code}&client-request-id=${clientRequestId}`; + }; + useEffect(() => { + if (code) { + if (!isVscode) { + launchVscode(); + } else { + if (isLoggedIn) { + return; + } + // Fetch JWT tokens and user info from backend when auth provider code is present + dispatch(SessionActions.fetchAuth(code, providerId)); + } + } + if (accessToken && refreshToken) { dispatch( SessionActions.setTokens({ @@ -27,10 +64,11 @@ const LoginVscodeCallback: React.FC = () => { dispatch(SessionActions.fetchUserAndCourse()); navigate('/welcome'); } + // Only isVscode is expected to change in the lifecycle // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [isVscode]); - return ( + return isVscode ? (
@@ -41,6 +79,30 @@ const LoginVscodeCallback: React.FC = () => {
+ ) : ( +
+ +
+
+

+ + Sign in with SSO +

+
+

+ Click Open link on the dialog shown by your browser. +

+

If you don't see a dialog, click the button below.

+
+ + + +
+
+
+
); }; diff --git a/src/routes/routerConfig.tsx b/src/routes/routerConfig.tsx index bcf5d326fe..4c13425b43 100644 --- a/src/routes/routerConfig.tsx +++ b/src/routes/routerConfig.tsx @@ -129,10 +129,11 @@ export const getFullAcademyRouterConfig = ({ { path: 'login', lazy: Login, - children: [ - { path: 'callback', lazy: LoginCallback }, - { path: 'vscode_callback', lazy: LoginVscodeCallback } - ] + children: [{ path: 'callback', lazy: LoginCallback }] + }, + { + path: 'login', + children: [{ path: 'vscode_callback', lazy: LoginVscodeCallback }] }, { path: 'welcome', lazy: Welcome, loader: welcomeLoader }, { path: 'courses', element: },