diff --git a/src/auth/AuthProvider/index.ts b/src/auth/AuthProvider/index.ts index bbff4f78..687644cc 100644 --- a/src/auth/AuthProvider/index.ts +++ b/src/auth/AuthProvider/index.ts @@ -1,43 +1,50 @@ -import { v4 as uuid } from "uuid"; import { authentication as vscodeAuth, - env, - window, - EventEmitter, - Disposable, - ProgressLocation, - Uri -} from "vscode"; - -import { PromiseAdapter, promiseFromEvent } from "./utils/promiseFromEvent"; -import UriEventHandler from "./utils/UriEventHandler"; -import { fetchPlatformData } from "../../webview/WebviewProvider/lib"; -import { SEQERA_PLATFORM_URL } from "../../constants"; - -import type { AuthenticationProvider, AuthenticationProviderAuthenticationSessionsChangeEvent as ChangeEvent, AuthenticationSession, + Disposable, + env, + EventEmitter, ExtensionContext, + ProgressLocation, + Uri, + window, WebviewView } from "vscode"; - -type ExchangePromise = { - promise: Promise; - cancel: EventEmitter; -}; +import { v4 as uuid } from "uuid"; +import { promiseFromEvent } from "./utils/promiseFromEvent"; +import fetch from "node-fetch"; +import UriEventHandler from "./utils/UriEventHandler"; +import { + AuthSession, + WebCallback, + User, + Auth0LoginType, + ResponseAuth0, + WebCallbackHandler +} from "./types"; +import { + fetchPlatformData, + clearPlatformData +} from "../../webview/WebviewProvider/lib"; +import { + AUTH0_CLIENT_ID, + AUTH0_CLIENT_SECRET, + AUTH0_DOMAIN, + AUTH0_SCOPES +} from "../../constants"; const TYPE = `auth0`; const NAME = `Seqera Cloud`; -const AUTH_ENDPOINT = `${SEQERA_PLATFORM_URL}/oauth/login/auth0?source=vscode`; -export const STORAGE_KEY_NAME = `${TYPE}.sessions`; +export const STORAGE_KEY = `${TYPE}.sessions`; class AuthProvider implements AuthenticationProvider, Disposable { - private uriHandler = new UriEventHandler(); private eventEmitter = new EventEmitter(); private currentInstance: Disposable; - private pendingIDs: string[] = []; // TODO: Does this do anything? - private promises = new Map(); + private pendingIDs: string[] = []; + private callbackEvents = new Map(); + private uriHandler = new UriEventHandler(); private webviewView!: WebviewView["webview"]; constructor(private readonly context: ExtensionContext) { @@ -52,53 +59,42 @@ class AuthProvider implements AuthenticationProvider, Disposable { return this.eventEmitter.event; } - get redirectUri() { - const publisher = this.context.extension.packageJSON.publisher; - const name = this.context.extension.packageJSON.name; - return `${env.uriScheme}://${publisher}.${name}`; - } - public async getSessions(): Promise { - const sessions = await this.context.secrets.get(STORAGE_KEY_NAME); - if (!sessions) return []; - return JSON.parse(sessions) as AuthenticationSession[]; + const allSessions = await this.context.secrets.get(STORAGE_KEY); + if (!allSessions) return []; + return JSON.parse(allSessions) as AuthenticationSession[]; } - public async createSession(scopes: string[]): Promise { + public async createSession(): Promise { try { - const accessToken = await this.login(scopes); - if (!accessToken) { - throw new Error(`Platform login failure`); + let accessToken; + let refreshToken; + + if (AUTH0_CLIENT_SECRET) { + // Note: this "code" response type is for allowing token refresh functionality. + // Use the Auth0 app's secret, and ensure "Allow Offline Access" is enabled. + const code = await this.startLogin("code"); + if (!code) throw new Error(`Auth0 login failure (code flow)`); + const auth0Response = await this.fetchAuth0Tokens(code); + accessToken = auth0Response.access_token; + refreshToken = auth0Response.refresh_token; + } else { + accessToken = await this.startLogin("token"); + if (!accessToken) throw new Error(`Auth0 login failure (token flow)`); } - const data = await fetchPlatformData( + // Login success, now fetch from Seqera Cloud + const { userInfo } = await fetchPlatformData( accessToken, this.webviewView, this.context ); - const { userInfo } = data; - const account = { - label: userInfo?.user?.userName || "Undefined", - id: userInfo?.user?.email || "undefined" - }; - - const session: AuthenticationSession = { - id: uuid(), - accessToken, - account, - scopes: [] - }; - - await this.context.secrets.store( - STORAGE_KEY_NAME, - JSON.stringify([session]) - ); + const user = userInfo?.user; + if (!user) throw new Error(`User not found`); - this.eventEmitter.fire({ - added: [session], - removed: [], - changed: [] - }); + // If that worked, store the vscode session + const session = await this.storeSession(user, accessToken, refreshToken); + if (!session) throw new Error(`Failed to store new session`); return session; } catch (e) { @@ -107,112 +103,177 @@ class AuthProvider implements AuthenticationProvider, Disposable { } } - public async removeSession(sessionId: string): Promise { - const allSessions = await this.context.secrets.get(STORAGE_KEY_NAME); - if (!allSessions) return; - let sessions = JSON.parse(allSessions) as AuthenticationSession[]; - const sessionIdx = sessions.findIndex((s) => s.id === sessionId); - const session = sessions[sessionIdx]; - sessions.splice(sessionIdx, 1); - - await this.context.secrets.store( - STORAGE_KEY_NAME, - JSON.stringify(sessions) - ); + private async storeSession( + user: User, + accessToken: string, + refreshToken?: string + ) { + const session: AuthSession = { + id: uuid(), + accessToken, + refreshToken, + account: { + label: user.userName, + id: user.email + }, + scopes: [] + }; - this.webviewView?.postMessage({ authState: {} }); - const vsCodeState = this.context.workspaceState; - vsCodeState.update("platformData", {}); + await this.context.secrets.store(STORAGE_KEY, JSON.stringify([session])); - if (session) { - this.eventEmitter.fire({ - added: [], - removed: [session], - changed: [] - }); + this.eventEmitter.fire({ + added: [session], + removed: [], + changed: [] + }); + + return session; + } + + public async removeSession(sessionId: string): Promise { + const allSessions = await this.context.secrets.get(STORAGE_KEY); + if (allSessions) { + let sessions = JSON.parse(allSessions) as AuthenticationSession[]; + const sessionIdx = sessions.findIndex((s) => s.id === sessionId); + const session = sessions[sessionIdx]; + sessions.splice(sessionIdx, 1); + + await this.context.secrets.store(STORAGE_KEY, JSON.stringify(sessions)); + + clearPlatformData(this.webviewView, this.context); + + if (session) { + this.eventEmitter.fire({ + added: [], + removed: [session], + changed: [] + }); + } } } - public async dispose() { - this.currentInstance.dispose(); + private async openBrowser(type: Auth0LoginType, stateId: string) { + const searchParams = new URLSearchParams([ + ["response_type", type], + ["client_id", AUTH0_CLIENT_ID], + ["redirect_uri", this.webCallbackURI], + ["state", stateId], + ["scope", AUTH0_SCOPES], + ["audience", "platform"], + ["prompt", "login"] + ]); + const uri = Uri.parse( + `https://${AUTH0_DOMAIN}/authorize?${searchParams.toString()}` + ); + await env.openExternal(uri); } - private async login(scopes: string[] = []) { - return await window.withProgress( + private async startLogin(type: Auth0LoginType) { + return await window.withProgress( { location: ProgressLocation.Notification, - title: "Signing in to Seqera Cloud...", + title: "Signing in to Seqera Cloud", cancellable: true }, - async (_, token) => { + async (_, cancellationToken) => { + // Add pending login ID to client const stateId = uuid(); - this.pendingIDs.push(stateId); - const scopeString = scopes.join(" "); + await this.openBrowser(type, stateId); - if (!scopes.includes("openid")) { - scopes.push("openid"); - } - if (!scopes.includes("profile")) { - scopes.push("profile"); - } - if (!scopes.includes("email")) { - scopes.push("email"); - } - - const uri = Uri.parse(AUTH_ENDPOINT); - await env.openExternal(uri); + let callback: WebCallback | undefined; - let codeExchangePromise = this.promises.get(scopeString); - if (!codeExchangePromise) { - codeExchangePromise = promiseFromEvent( + try { + const pendingCallback = this.callbackEvents.get(AUTH0_SCOPES); + callback = pendingCallback; + callback ??= promiseFromEvent( this.uriHandler.event, - this.handleUri(scopes) + this.webCallbackHandler + ); + this.callbackEvents.set(AUTH0_SCOPES, callback); + + const userCancel = promiseFromEvent( + cancellationToken.onCancellationRequested, + (_, __, reject) => { + reject("User Cancelled"); + } + ); + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject("Timed out"), 60000) ); - this.promises.set(scopeString, codeExchangePromise); - } - try { return await Promise.race([ - codeExchangePromise.promise, - new Promise((_, reject) => - setTimeout(() => reject("Cancelled"), 60000) - ), - promiseFromEvent( - token.onCancellationRequested, - (_, __, reject) => { - reject("User Cancelled"); - } - ).promise + callback.promise, + userCancel.promise, + timeoutPromise ]); } finally { this.pendingIDs = this.pendingIDs.filter((n) => n !== stateId); - codeExchangePromise?.cancel.fire(); - this.promises.delete(scopeString); + callback?.cancel.fire(); + this.callbackEvents.delete(AUTH0_SCOPES); } } ); } - private handleUri: ( - scopes: readonly string[] - ) => PromiseAdapter = - (scopes) => async (uri, resolve, reject) => { - const query = new URLSearchParams(uri.fragment); - const accessToken = query.get("access_token"); + get webCallbackURI() { + const publisher = this.context.extension.packageJSON.publisher; + const name = this.context.extension.packageJSON.name; + return `vscode://${publisher}.${name}`; + } - if (!accessToken) { - reject(new Error("No token")); - return; - } + private webCallbackHandler: WebCallbackHandler = async ( + uri, + resolve, + reject + ) => { + const queryString = uri.query || uri.fragment; + const query = new URLSearchParams(queryString); + const accessToken = query.get("code") || query.get("access_token"); + const state = query.get("state"); + + if (!accessToken) { + reject(new Error("No token")); + return; + } + if (!state) { + reject(new Error("No state")); + return; + } - resolve(accessToken); - }; + if (!this.pendingIDs.some((n) => n === state)) { + reject(new Error("Pending login not found")); + return; + } + + resolve(accessToken); + }; + + private async fetchAuth0Tokens(code: string): Promise { + const data = new URLSearchParams([ + ["grant_type", "authorization_code"], + ["client_id", AUTH0_CLIENT_ID], + ["client_secret", AUTH0_CLIENT_SECRET], + ["code", code], + ["redirect_uri", this.webCallbackURI] + ]); + const auth = await fetch(`https://${AUTH0_DOMAIN}/oauth/token`, { + method: `POST`, + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: data.toString() + }); + const res = (await auth.json()) as ResponseAuth0; + return res; + } public setWebview(webview: any) { this.webviewView = webview; } + + public async dispose() { + this.currentInstance.dispose(); + } } export default AuthProvider; diff --git a/src/auth/AuthProvider/types.ts b/src/auth/AuthProvider/types.ts index 9f17b7b9..9e29f8b8 100644 --- a/src/auth/AuthProvider/types.ts +++ b/src/auth/AuthProvider/types.ts @@ -1,3 +1,6 @@ +import { AuthenticationSession, EventEmitter, Uri } from "vscode"; +import { PromiseAdapter } from "./utils/promiseFromEvent"; + export type User = { id: number; userName: string; @@ -28,3 +31,37 @@ export type UserInfo = { needConsent: boolean; defaultWorkspaceId: number; }; + +export type WebCallback = { + promise: Promise; + cancel: EventEmitter; +}; + +export type AuthSession = AuthenticationSession & { + refreshToken?: string; +}; + +export type ResponseAuth0 = { + access_token: string; + refresh_token?: string; + token_type: "Bearer"; + expires_in: number; + scope: string; + id_token: string; +}; + +export type UserInfoAuth0 = { + email: string; + email_verified: boolean; + family_name: string; + given_name: string; + name: string; + nickname: string; + picture: string; + preferred_username: string; + sub: string; + updated_at: string; +}; + +export type Auth0LoginType = "code" | "token"; +export type WebCallbackHandler = PromiseAdapter; diff --git a/src/auth/getAccessToken.ts b/src/auth/getAccessToken.ts index cd14da3f..9cc3dff5 100644 --- a/src/auth/getAccessToken.ts +++ b/src/auth/getAccessToken.ts @@ -1,14 +1,31 @@ -import { AuthenticationSession, ExtensionContext } from "vscode"; +import { ExtensionContext } from "vscode"; +import { jwtExpired } from "./AuthProvider/utils/jwt"; -import { STORAGE_KEY_NAME } from "./AuthProvider"; +import { STORAGE_KEY } from "./AuthProvider"; +import refreshAccessToken from "./refreshAccessToken"; +import { AuthSession } from "./AuthProvider/types"; + +export type Auth0TokenResponse = { + access_token: string; + refresh_token?: string; + expires_in: number; + token_type: string; + scope: string; +}; const getAccessToken = async ( context: ExtensionContext ): Promise => { - const sessionsStr = await context.secrets.get(STORAGE_KEY_NAME); + const sessionsStr = await context.secrets.get(STORAGE_KEY); const sessions = sessionsStr ? JSON.parse(sessionsStr) : []; - const session = sessions[0] as AuthenticationSession; - const token = session?.accessToken; + const session = sessions[0] as AuthSession; + let token = session?.accessToken; + + // Check if token is expired + if (token && jwtExpired(token)) { + const newToken = await refreshAccessToken(session, context); + if (newToken) return newToken; + } return token; }; diff --git a/src/auth/refreshAccessToken.ts b/src/auth/refreshAccessToken.ts new file mode 100644 index 00000000..88729e1a --- /dev/null +++ b/src/auth/refreshAccessToken.ts @@ -0,0 +1,52 @@ +// import fetch from "node-fetch"; + +import { STORAGE_KEY } from "./AuthProvider"; +import { + AUTH0_CLIENT_ID, + AUTH0_CLIENT_SECRET, + AUTH0_DOMAIN +} from "../constants"; +import { Auth0TokenResponse } from "./getAccessToken"; +import { ExtensionContext } from "vscode"; +import { AuthSession } from "./AuthProvider/types"; + +const refreshAccessToken = async ( + session: AuthSession, + context: ExtensionContext +): Promise => { + const refreshToken = session.refreshToken; + if (!refreshToken) return undefined; + + try { + const data = new URLSearchParams([ + ["grant_type", "refresh_token"], + ["client_id", AUTH0_CLIENT_ID], + ["client_secret", AUTH0_CLIENT_SECRET], + ["refresh_token", refreshToken] + ]); + + const response = await fetch(`${AUTH0_DOMAIN}/oauth/token`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: data.toString() + }); + + const tokens = (await response.json()) as Auth0TokenResponse; + const { access_token, refresh_token } = tokens; + if (!access_token) throw new Error(`No new access token`); + + const updatedSession: AuthSession = { + ...session, + accessToken: access_token, + refreshToken: refresh_token || session.refreshToken + }; + await context.secrets.store(STORAGE_KEY, JSON.stringify([updatedSession])); + return access_token; + } catch (err) { + console.log("🔴 Failed to refresh token", err); + return undefined; + } + return undefined; +}; + +export default refreshAccessToken; diff --git a/src/constants.ts b/src/constants.ts index 5e9e9d6d..9e8df721 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,4 +1,14 @@ -export const SEQERA_PLATFORM_URL = `https://cloud.seqera.io`; +// TODO: Restore to cloud.seqera.io +export const SEQERA_PLATFORM_URL = `https://pr-8246.dev-tower.net`; export const SEQERA_API_URL = `${SEQERA_PLATFORM_URL}/api`; export const SEQERA_HUB_API_URL = `https://hub.seqera.io`; export const SEQERA_INTERN_API_URL = `https://intern.seqera.io`; + +// TODO: Use env var to store the Auth0 secret +// TODO: Update to production Auth0 app +// TODO: Security implications of rolling up this secret into the built extension +export const AUTH0_CLIENT_SECRET = + "tZ3N8vHuvpLQlzdGEhel4Vz5DeluNNyTtid-2jFBdDiXmIGNbX9yhjDmQ2Pg6VT-"; +export const AUTH0_CLIENT_ID = "7PJnvIXiXK3HkQR43c4zBf3bWuxISp9W"; +export const AUTH0_SCOPES = "openid profile email offline_access"; +export const AUTH0_DOMAIN = `seqera-development.eu.auth0.com`; diff --git a/src/webview/WebviewProvider/index.ts b/src/webview/WebviewProvider/index.ts index 793268c3..cf5da85f 100644 --- a/src/webview/WebviewProvider/index.ts +++ b/src/webview/WebviewProvider/index.ts @@ -181,7 +181,6 @@ class WebviewProvider implements vscode.WebviewViewProvider { public async initViewData(refresh?: boolean) { const { viewID, _context, _currentView: view } = this; - console.log("🟠 initViewData", viewID); if (!view) return; if (viewID === "seqeraCloud") { this.getRepoInfo(); @@ -211,7 +210,7 @@ class WebviewProvider implements vscode.WebviewViewProvider { const created = await createTest(filePath, accessToken); this.emitTestCreated(filePath, created); } catch (error) { - console.log("🟠 Test creation failed", error); + console.log("🔴 Test creation failed", error); this.emitTestCreated(filePath, false); } } @@ -227,12 +226,11 @@ class WebviewProvider implements vscode.WebviewViewProvider { private async getContainer(filePath: string) { const accessToken = await getAccessToken(this._context); - try { const created = await getContainer(filePath, accessToken); this.emitContainerCreated(filePath, created); } catch (error) { - console.log("🟠 Container creation failed", error); + console.log("🔴 Container creation failed", error); this.emitContainerCreated(filePath, false); } } diff --git a/src/webview/WebviewProvider/lib/index.ts b/src/webview/WebviewProvider/lib/index.ts index 9fc24062..6a6ae1e7 100644 --- a/src/webview/WebviewProvider/lib/index.ts +++ b/src/webview/WebviewProvider/lib/index.ts @@ -1,4 +1,5 @@ export { default as fetchPlatformData } from "./platform/fetchPlatformData"; +export { default as clearPlatformData } from "./platform/clearPlatformData"; export { default as getAuthState } from "./platform/getAuthState"; export * from "./platform/utils"; diff --git a/src/webview/WebviewProvider/lib/platform/clearPlatformData.ts b/src/webview/WebviewProvider/lib/platform/clearPlatformData.ts new file mode 100644 index 00000000..cede2359 --- /dev/null +++ b/src/webview/WebviewProvider/lib/platform/clearPlatformData.ts @@ -0,0 +1,12 @@ +import { ExtensionContext, WebviewView } from "vscode"; + +const clearPlatformData = async ( + view: WebviewView["webview"] | undefined, + context: ExtensionContext +) => { + view?.postMessage({ authState: {} }); + const vsCodeState = context.workspaceState; + vsCodeState.update("platformData", {}); +}; + +export default clearPlatformData; diff --git a/src/webview/WebviewProvider/lib/platform/utils/fetchUserInfo.ts b/src/webview/WebviewProvider/lib/platform/utils/fetchUserInfo.ts index 213b31e9..f074c6c7 100644 --- a/src/webview/WebviewProvider/lib/platform/utils/fetchUserInfo.ts +++ b/src/webview/WebviewProvider/lib/platform/utils/fetchUserInfo.ts @@ -9,7 +9,7 @@ const fetchUserInfo = async (token: string): Promise => { Authorization: `Bearer ${token}` } }); - console.log("🟣 fetchUserInfo", response.status); + console.log("🟣 fetchUserInfo", response); if (response.status === 401) { throw new Error("Unauthorized"); } diff --git a/webview-ui/src/Layout/SeqeraCloud/Toolbar/WorkspaceSelector.tsx b/webview-ui/src/Layout/SeqeraCloud/Toolbar/WorkspaceSelector.tsx index 1f0d2dba..915c07ca 100644 --- a/webview-ui/src/Layout/SeqeraCloud/Toolbar/WorkspaceSelector.tsx +++ b/webview-ui/src/Layout/SeqeraCloud/Toolbar/WorkspaceSelector.tsx @@ -2,6 +2,7 @@ import { useTowerContext } from "../../../Context"; import Select from "../../../components/Select"; import { getWorkspaceURL } from "../utils"; import Button from "../../../components/Button"; +import { SEQERA_PLATFORM_URL } from "../../../../../src/constants"; const WorkspaceSelector = () => { const { @@ -36,7 +37,9 @@ const WorkspaceSelector = () => { subtle /> ) : ( -
No workspaces found
+ )} {!!manageURL && (