UNPKG

@codesandbox/api

Version:
361 lines 14.4 kB
import { States } from "class-states"; import Cookies from "universal-cookie"; import { COOKIES, DAY, WAIT_FOR_SIGN_IN_MESSAGE_TIMEOUT, LOCAL_STORAGE_PREVIEW_JWT_KEY, } from "./constants"; import { showCliLoginModal, showLoginModal } from "./loginModalUI"; import { GITHUB_BASE_SCOPE, MAP_GITHUB_SCOPE_OPTIONS, openPopup, waitForMessage, } from "./utils"; export class SessionApi { constructor(options) { this.state = new States({ state: "UNAUTHENTICATED" }); this.onSessionChange = (cb) => this.state.onTransition((state, prevState) => { if (state.state !== prevState.state) { cb({ session: state, prevSession: prevState }); } }); this.cookies = new Cookies(); this.baseUrl = options.baseUrl; this.rootRequest = options.rootRequest; this.apiRequest = options.apiRequest; this.rootApiRequest = options.rootApiRequest; this.useCliAuthentication = options.useCliAuthentication; if (options.useCliAuthentication) try { this.bearerToken = this.cookies.get(COOKIES.DEV_JWT) || (typeof window !== "undefined" && window.localStorage.getItem(LOCAL_STORAGE_PREVIEW_JWT_KEY)); } catch (_a) { // No access to local storage or cookies } // We have already authenticated through some other mechanism, typically server side rendering if (options.user) { this.state.set({ state: "AUTHENTICATED", user: options.user, }); } // We automatically authenticate when we are in the browser, but only // if we detect through the cookie that you have already signed in or we have set a local // JWT else if (typeof window !== "undefined" && (this.hasSignedIn() || this.bearerToken)) { this.state.set({ state: "UNAUTHENTICATED", transitionState: { state: "AUTHENTICATING", transition: this.authenticate(), }, }); } this.state.onTransition("UNAUTHENTICATED", ({ transitionState }) => { // We only clear the cookies when we are not currently in an authentication transition if (!transitionState) { this.clearSignedIn(); } }); } /** * This is only used for the development flow, we NEVER set this cookie outside the context * of doing a development sign in */ setDevBearerToken(jwt) { this.bearerToken = jwt; this.cookies.set(COOKIES.SIGNED_IN, true, { expires: new Date(Date.now() + DAY * 30), sameSite: "strict", secure: true, path: "/", }); this.cookies.set(COOKIES.DEV_JWT, jwt, { expires: new Date(Date.now() + DAY * 30), sameSite: "strict", secure: true, path: "/", }); } clearSignedIn() { delete this.bearerToken; this.cookies.remove(COOKIES.DEV_JWT, { path: "/" }); this.cookies.remove(COOKIES.SIGNED_IN, { path: "/" }); } /** * Indicates if the user has signed in and we should authenticate */ hasSignedIn() { return Boolean(this.cookies.get(COOKIES.SIGNED_IN)); } async authenticate() { try { // HACK: We can not synchronously run `this.apiRequest` because it depends on Session being // instantiated to get the JWT token, but as part of instantiating Session we need to make // this request. I have not found a better way to deal with this yet, but only affects development await Promise.resolve(); const { data: user } = await this.apiRequest({ method: "GET", path: "/users/current", }); return this.state.set({ state: "AUTHENTICATED", user, }); } catch (error) { return this.state.set({ state: "UNAUTHENTICATED", }); } } getPendingUser(id) { return this.apiRequest({ path: "/users/pending/" + id, method: "GET" }); } finalizeSignUp(data) { return this.apiRequest({ path: "/users/finalize", method: "POST", data }); } showProviderPopup(provider) { let authPath = new URL(this.baseUrl + `/auth/${provider.type}`); authPath.searchParams.set("version", "2"); if (provider.type === "github") { let scope = GITHUB_BASE_SCOPE; if (provider.includedScopes) { scope = GITHUB_BASE_SCOPE + "," + MAP_GITHUB_SCOPE_OPTIONS[provider.includedScopes]; } if (provider.rawScopes) { scope = [...provider.rawScopes].join(","); if (!scope.includes(GITHUB_BASE_SCOPE)) { scope = GITHUB_BASE_SCOPE + "," + scope; } } authPath.searchParams.set("scope", scope); } else if (provider.type === "sso") { authPath = new URL(this.baseUrl + provider.redirectUrl); } const popup = openPopup(authPath.href, "sign in"); const timeout = new Promise((resolve) => { setTimeout(() => { resolve(); }, WAIT_FOR_SIGN_IN_MESSAGE_TIMEOUT); }); return Promise.race([ popup.closePromise, waitForMessage((message) => { if (message.type === "signin" || message.type === "duplicate" || message.type === "signup") { return message; } }), timeout.then(() => { throw new Error("TIMEOUT"); }), ]) .then(async (possibleMessage) => { // The popup was cancelled if (!possibleMessage) { // With CLI token authentication we reauthenticate, cause we might have updated GitHub scopes return this.useCliAuthentication ? this.authenticate() : this.state.set({ state: "UNAUTHENTICATED", }); } popup.close(); switch (possibleMessage.type) { case "signup": { const pendingUser = await this.getPendingUser(possibleMessage.data.id); if (!pendingUser) { return this.state.set({ state: "UNAUTHENTICATED", error: "SIGNUP_FAILED", }); } await this.finalizeSignUp(pendingUser); return this.authenticate(); } case "signin": { return this.authenticate(); } case "duplicate": { return this.state.set({ state: "UNAUTHENTICATED", error: "DUPLICATE", }); } } }) .catch((error) => this.state.set({ state: "UNAUTHENTICATED", error: error.message, })); } signInWithPopup(provider) { // The "useCliAuthentication" currently does not work outside of CodeSandbox. The reason is that // we use the ?dev=true query which is only available on stream. This automatically signs you in. // If we are to support CLI login outside of CodeSandbox (When this package is open source), we would // have to create a separate login modal flow where the user copy pastes the CLI token const useDevCliLogin = this.useCliAuthentication && this.state.is("UNAUTHENTICATED"); if (useDevCliLogin) { const popup = openPopup(`${this.baseUrl}/cli/login?dev=true`, "sign in CLI"); return this.state.set({ state: "UNAUTHENTICATED", transitionState: { state: "SIGNING_IN", transition: showCliLoginModal().then(({ token, dispose }) => { popup.close(); if (!token) { dispose(); return this.state.set({ state: "UNAUTHENTICATED", }); } return this.apiRequest({ method: "GET", path: `/auth/verify/${token}`, }).then(({ data: { token } }) => { this.setDevBearerToken(token); return this.authenticate().then((result) => { dispose(); return result; }); }); }), }, }); } if (!provider) { const metaFeaturesPromise = this.rootApiRequest({ method: "GET", path: "/meta/features", }); const getSSORedirectUrl = (email) => this.rootRequest({ method: "GET", path: "/auth/workos/initialize?email=" + encodeURIComponent(email), }).then((result) => result.redirectUrl); return this.state.set({ state: "UNAUTHENTICATED", transitionState: { state: "SIGNING_IN", transition: metaFeaturesPromise.then((metaFeatures) => showLoginModal(metaFeatures, getSSORedirectUrl).then(({ provider, dispose }) => { if (provider) { return this.showProviderPopup(provider).then((result) => { dispose(); return result; }); } dispose(); return this.state.set({ state: "UNAUTHENTICATED", }); })), }, }); } return this.state.set(Object.assign(Object.assign({}, this.state.get()), { transitionState: { state: "SIGNING_IN", transition: this.showProviderPopup(provider), } })); } /** * If we receive an unauthorized response we want to use that information to ensure you are UNAUTHENTICATED */ async unauthorizedResponseReceived() { if (this.state.is("AUTHENTICATED")) { this.state.set({ state: "UNAUTHENTICATED", }); } } async authenticateWithJwt(jwt) { return this.state.set({ state: "UNAUTHENTICATED", transitionState: { state: "AUTHENTICATING", transition: this.apiRequest({ method: "GET", path: `/auth/verify/${jwt}`, }).then(({ data: { token } }) => { this.bearerToken = token; window.localStorage.setItem(LOCAL_STORAGE_PREVIEW_JWT_KEY, token); return this.authenticate().then((result) => result); }), }, }).transitionState.transition; } async signIn(provider) { const signInState = await this.state.match({ // We allow you to sign in again as the provider can be changed or updated (GitHub) AUTHENTICATED: (state) => state.transitionState ? state.transitionState.transition : this.signInWithPopup(provider).transitionState.transition, UNAUTHENTICATED: (state) => state.transitionState ? state.transitionState.transition : this.signInWithPopup(provider).transitionState.transition, }); return this.state.match(signInState, { AUTHENTICATED: ({ user }) => user, UNAUTHENTICATED: () => null, }); } async signOut() { const signOutState = await this.state.match({ AUTHENTICATED: (state) => state.transitionState ? state.transitionState.transition : this.state.set({ state: "AUTHENTICATED", user: state.user, transitionState: { state: "SIGNING_OUT", transition: this.apiRequest({ method: "DELETE", path: "/users/signout", }) .then(() => this.state.set({ state: "UNAUTHENTICATED", })) .catch(() => this.state.set({ state: "AUTHENTICATED", user: state.user, })), }, }).transitionState.transition, UNAUTHENTICATED: (state) => state, }); return this.state.match(signOutState, { AUTHENTICATED: () => { throw new Error("Not able to sign you out"); }, UNAUTHENTICATED: () => { }, }); } /** * Used for requests that can not rely on the HTTP only cookie, for example GQL subscription requests */ getGuardianToken() { return this.apiRequest({ method: "GET", path: "/auth/jwt", }).then((data) => data.jwt); } revokeToken(token) { return this.apiRequest({ method: "DELETE", path: "/auth/revoke/" + token, }); } getAuthToken() { return this.apiRequest({ method: "GET", path: "/auth/auth-token", }); } verifyToken(token) { return this.apiRequest({ method: "GET", path: "/auth/verify/" + token, }); } } //# sourceMappingURL=SessionApi.js.map