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: },