@codesandbox/api
Version:
The CodeSandbox API
361 lines • 14.4 kB
JavaScript
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