From 3dd92c2ccc91ac7538cd859f601c3a4bfe555927 Mon Sep 17 00:00:00 2001 From: heyzec <61238538+heyzec@users.noreply.github.com> Date: Fri, 27 Jun 2025 21:28:10 +0800 Subject: [PATCH 1/8] feat: OAuth flow with VS Code --- src/commons/sagas/BackendSaga.ts | 3 +- src/commons/sagas/LoginSaga.ts | 12 +++-- src/commons/utils/AuthHelper.ts | 14 +++-- src/features/vscode/messages.ts | 5 +- src/pages/login/LoginVscodeCallback.tsx | 68 +++++++++++++++++++++++-- 5 files changed, 87 insertions(+), 15 deletions(-) diff --git a/src/commons/sagas/BackendSaga.ts b/src/commons/sagas/BackendSaga.ts index abca6e3d09..4551bbeaa1 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 = yield select(state => 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/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/features/vscode/messages.ts b/src/features/vscode/messages.ts index 4d0dbf15d2..270f1c2333 100644 --- a/src/features/vscode/messages.ts +++ b/src/features/vscode/messages.ts @@ -16,7 +16,10 @@ const Messages = createMessages({ questionId, code }), - Text: (code: string) => ({ code }) + Text: (code: string) => ({ code }), + LoginWithBrowser: (route: string) => ({ + route + }) }); export default Messages; diff --git a/src/pages/login/LoginVscodeCallback.tsx b/src/pages/login/LoginVscodeCallback.tsx index 145d8a3793..2f6f32edb6 100644 --- a/src/pages/login/LoginVscodeCallback.tsx +++ b/src/pages/login/LoginVscodeCallback.tsx @@ -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'; @@ -13,10 +27,31 @@ const LoginVscodeCallback: React.FC = () => { const dispatch = useDispatch(); const location = useLocation(); const { t } = useTranslation('login'); - + const { isLoggedIn } = useSession(); + const { code, ticket, provider: providerId } = 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}&provider=${providerId}`; + }; + 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({ @@ -27,10 +62,9 @@ const LoginVscodeCallback: React.FC = () => { dispatch(SessionActions.fetchUserAndCourse()); navigate('/welcome'); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [isVscode]); - return ( + return isVscode ? (
@@ -41,6 +75,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.

+
+ + + +
+
+
+
); }; From 14c8eb703a2c114b051924435719a0d5501c76da Mon Sep 17 00:00:00 2001 From: heyzec <61238538+heyzec@users.noreply.github.com> Date: Fri, 27 Jun 2025 22:06:06 +0800 Subject: [PATCH 2/8] fix: account for diff btw local mock and NUSNET --- src/pages/login/LoginVscodeCallback.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/pages/login/LoginVscodeCallback.tsx b/src/pages/login/LoginVscodeCallback.tsx index 2f6f32edb6..9ab96fb7f1 100644 --- a/src/pages/login/LoginVscodeCallback.tsx +++ b/src/pages/login/LoginVscodeCallback.tsx @@ -28,7 +28,12 @@ const LoginVscodeCallback: React.FC = () => { const location = useLocation(); const { t } = useTranslation('login'); const { isLoggedIn } = useSession(); - const { code, ticket, provider: providerId } = parseQuery(location.search); + 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); @@ -36,7 +41,7 @@ const LoginVscodeCallback: React.FC = () => { const authCode = code || ticket; const launchVscode = () => { - window.location.href = `vscode://source-academy.source-academy/sso?code=${authCode}&provider=${providerId}`; + window.location.href = `vscode://source-academy.source-academy/sso?code=${authCode}&client-request-id=${clientRequestId}`; }; useEffect(() => { From b43978c72b648773dd9e10157c5d79e68464d9cd Mon Sep 17 00:00:00 2001 From: heyzec <61238538+heyzec@users.noreply.github.com> Date: Sat, 28 Jun 2025 01:14:40 +0800 Subject: [PATCH 3/8] fix tests due to missing vscode slice unfortunately, mockStates does not have "satisfies OverallState" --- src/commons/sagas/BackendSaga.ts | 2 +- src/commons/sagas/__tests__/BackendSaga.ts | 13 ++++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/commons/sagas/BackendSaga.ts b/src/commons/sagas/BackendSaga.ts index 4551bbeaa1..19f0dc4ad3 100644 --- a/src/commons/sagas/BackendSaga.ts +++ b/src/commons/sagas/BackendSaga.ts @@ -121,7 +121,7 @@ export function* routerNavigate(path: string) { const newBackendSagaOne = combineSagaHandlers({ [SessionActions.fetchAuth.type]: function* (action): any { const { code, providerId: payloadProviderId } = action.payload; - const isVscode = yield select(state => state.vscode.isVscode); + const isVscode: boolean = yield select((state: OverallState) => state.vscode.isVscode); const providerId = payloadProviderId || (getDefaultProvider() || [null])[0]; if (!providerId) { 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], [ From 54333f178e43a75dea8df032c917c469d7f2f4cf Mon Sep 17 00:00:00 2001 From: Richard Dominick <34370238+RichDom2185@users.noreply.github.com> Date: Sat, 28 Jun 2025 02:05:07 +0800 Subject: [PATCH 4/8] chore: Reformat --- src/features/vscode/messages.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/features/vscode/messages.ts b/src/features/vscode/messages.ts index 270f1c2333..9cd0f4caf2 100644 --- a/src/features/vscode/messages.ts +++ b/src/features/vscode/messages.ts @@ -17,9 +17,7 @@ const Messages = createMessages({ code }), Text: (code: string) => ({ code }), - LoginWithBrowser: (route: string) => ({ - route - }) + LoginWithBrowser: (route: string) => ({ route }) }); export default Messages; From 095e85ce59709081c2676cab217135b61f315e85 Mon Sep 17 00:00:00 2001 From: heyzec <61238538+heyzec@users.noreply.github.com> Date: Sat, 28 Jun 2025 13:01:54 +0800 Subject: [PATCH 5/8] remove `ticket` --- src/pages/login/LoginVscodeCallback.tsx | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/pages/login/LoginVscodeCallback.tsx b/src/pages/login/LoginVscodeCallback.tsx index 9ab96fb7f1..0e2d497d98 100644 --- a/src/pages/login/LoginVscodeCallback.tsx +++ b/src/pages/login/LoginVscodeCallback.tsx @@ -30,22 +30,18 @@ const LoginVscodeCallback: React.FC = () => { 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}`; + window.location.href = `vscode://source-academy.source-academy/sso?code=${code}&client-request-id=${clientRequestId}`; }; useEffect(() => { - if (authCode) { + if (code) { if (!isVscode) { launchVscode(); } else { @@ -53,7 +49,7 @@ const LoginVscodeCallback: React.FC = () => { return; } // Fetch JWT tokens and user info from backend when auth provider code is present - dispatch(SessionActions.fetchAuth(authCode, providerId)); + dispatch(SessionActions.fetchAuth(code, providerId)); } } From e3abbbf4198ee68c9968992fece067ad5a41ed18 Mon Sep 17 00:00:00 2001 From: heyzec <61238538+heyzec@users.noreply.github.com> Date: Sat, 28 Jun 2025 15:20:31 +0800 Subject: [PATCH 6/8] fix: squelch warning (eslint) --- src/pages/login/LoginVscodeCallback.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pages/login/LoginVscodeCallback.tsx b/src/pages/login/LoginVscodeCallback.tsx index 0e2d497d98..5b4815c70e 100644 --- a/src/pages/login/LoginVscodeCallback.tsx +++ b/src/pages/login/LoginVscodeCallback.tsx @@ -63,6 +63,8 @@ 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 isVscode ? ( From eb585707f547fd41bcbf65d3aa9af9921ac2d66f Mon Sep 17 00:00:00 2001 From: heyzec <61238538+heyzec@users.noreply.github.com> Date: Sat, 28 Jun 2025 16:07:07 +0800 Subject: [PATCH 7/8] refactor: move vscode baseurl to Constants.ts --- src/commons/utils/Constants.ts | 4 +++- src/pages/login/LoginVscodeCallback.tsx | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) 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/pages/login/LoginVscodeCallback.tsx b/src/pages/login/LoginVscodeCallback.tsx index 5b4815c70e..041945364d 100644 --- a/src/pages/login/LoginVscodeCallback.tsx +++ b/src/pages/login/LoginVscodeCallback.tsx @@ -17,6 +17,7 @@ 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'; @@ -37,7 +38,7 @@ const LoginVscodeCallback: React.FC = () => { const { access_token: accessToken, refresh_token: refreshToken } = parseQuery(location.search); const launchVscode = () => { - window.location.href = `vscode://source-academy.source-academy/sso?code=${code}&client-request-id=${clientRequestId}`; + window.location.href = `${Links.vscode}/sso?code=${code}&client-request-id=${clientRequestId}`; }; useEffect(() => { From ecdd57309c22f165db7b7f7dff09dccb36ed6efb Mon Sep 17 00:00:00 2001 From: heyzec <61238538+heyzec@users.noreply.github.com> Date: Sun, 29 Jun 2025 23:31:21 +0800 Subject: [PATCH 8/8] fix: don't redir to /courses for vscode_callback --- src/routes/routerConfig.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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: },