Skip to content

feat: OAuth flow for VS Code #3201

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Jun 29, 2025
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