aboutsummaryrefslogtreecommitdiff
path: root/packages/studio/src/core/tokens.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/studio/src/core/tokens.ts')
-rw-r--r--packages/studio/src/core/tokens.ts225
1 files changed, 225 insertions, 0 deletions
diff --git a/packages/studio/src/core/tokens.ts b/packages/studio/src/core/tokens.ts
new file mode 100644
index 000000000..cb43d70ff
--- /dev/null
+++ b/packages/studio/src/core/tokens.ts
@@ -0,0 +1,225 @@
+import { readFile } from 'node:fs/promises';
+import { homedir } from 'node:os';
+import { join } from 'node:path';
+import { pathToFileURL } from 'node:url';
+import ci from 'ci-info';
+import { green } from 'kleur/colors';
+import yoctoSpinner from 'yocto-spinner';
+import {
+ MISSING_PROJECT_ID_ERROR,
+ MISSING_SESSION_ID_CI_ERROR,
+ MISSING_SESSION_ID_ERROR,
+} from './errors.js';
+import { getAstroStudioEnv, getAstroStudioUrl } from './utils.js';
+
+export const SESSION_LOGIN_FILE = pathToFileURL(join(homedir(), '.astro', 'session-token'));
+export const PROJECT_ID_FILE = pathToFileURL(join(process.cwd(), '.astro', 'link'));
+
+export interface ManagedAppToken {
+ token: string;
+ renew(): Promise<void>;
+ destroy(): Promise<void>;
+}
+
+class ManagedLocalAppToken implements ManagedAppToken {
+ token: string;
+ constructor(token: string) {
+ this.token = token;
+ }
+ async renew() {}
+ async destroy() {}
+}
+
+class ManagedRemoteAppToken implements ManagedAppToken {
+ token: string;
+ session: string;
+ projectId: string;
+ ttl: number;
+ expires: Date;
+ renewTimer: NodeJS.Timeout | undefined;
+
+ static async create(sessionToken: string, projectId: string) {
+ const { token: shortLivedAppToken, ttl } = await this.createToken(sessionToken, projectId);
+ return new ManagedRemoteAppToken({
+ token: shortLivedAppToken,
+ session: sessionToken,
+ projectId,
+ ttl,
+ });
+ }
+
+ static async createToken(
+ sessionToken: string,
+ projectId: string,
+ ): Promise<{ token: string; ttl: number }> {
+ const spinner = yoctoSpinner({ text: 'Connecting to remote database...' }).start();
+ const response = await safeFetch(
+ new URL(`${getAstroStudioUrl()}/auth/cli/token-create`),
+ {
+ method: 'POST',
+ headers: new Headers({
+ Authorization: `Bearer ${sessionToken}`,
+ }),
+ body: JSON.stringify({ projectId }),
+ },
+ (res) => {
+ throw new Error(`Failed to create token: ${res.status} ${res.statusText}`);
+ },
+ );
+ spinner.success(green('Connected to remote database.'));
+
+ const { token, ttl } = await response.json();
+ return { token, ttl };
+ }
+
+ constructor(options: { token: string; session: string; projectId: string; ttl: number }) {
+ this.token = options.token;
+ this.session = options.session;
+ this.projectId = options.projectId;
+ this.ttl = options.ttl;
+ this.renewTimer = setTimeout(() => this.renew(), (1000 * 60 * 5) / 2);
+ this.expires = getExpiresFromTtl(this.ttl);
+ }
+
+ private async fetch(url: string, body: Record<string, unknown>) {
+ return safeFetch(
+ `${getAstroStudioUrl()}${url}`,
+ {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${this.session}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(body),
+ },
+ () => {
+ throw new Error(`Failed to fetch ${url}.`);
+ },
+ );
+ }
+
+ tokenIsValid() {
+ return new Date() > this.expires;
+ }
+
+ createRenewTimer() {
+ return setTimeout(() => this.renew(), (1000 * 60 * this.ttl) / 2);
+ }
+
+ async renew() {
+ clearTimeout(this.renewTimer);
+ delete this.renewTimer;
+
+ if (this.tokenIsValid()) {
+ const response = await this.fetch('/auth/cli/token-renew', {
+ token: this.token,
+ projectId: this.projectId,
+ });
+ if (response.status === 200) {
+ this.expires = getExpiresFromTtl(this.ttl);
+ this.renewTimer = this.createRenewTimer();
+ } else {
+ throw new Error(`Unexpected response: ${response.status} ${response.statusText}`);
+ }
+ } else {
+ try {
+ const { token, ttl } = await ManagedRemoteAppToken.createToken(
+ this.session,
+ this.projectId,
+ );
+ this.token = token;
+ this.ttl = ttl;
+ this.expires = getExpiresFromTtl(ttl);
+ this.renewTimer = this.createRenewTimer();
+ } catch {
+ // If we get here we couldn't create a new token. Since the existing token
+ // is expired we really can't do anything and should exit.
+ throw new Error(
+ `Token has expired and attempts to renew it have failed, please try again.`,
+ );
+ }
+ }
+ }
+
+ async destroy() {
+ try {
+ const response = await this.fetch('/auth/cli/token-delete', {
+ token: this.token,
+ projectId: this.projectId,
+ });
+ if (response.status !== 200) {
+ throw new Error(`Unexpected response: ${response.status} ${response.statusText}`);
+ }
+ } catch (error: any) {
+ console.error('Failed to delete token.', error?.message);
+ }
+ }
+}
+
+export async function getProjectIdFromFile() {
+ try {
+ return await readFile(PROJECT_ID_FILE, 'utf-8');
+ } catch {
+ return undefined;
+ }
+}
+
+export async function getSessionIdFromFile() {
+ try {
+ return await readFile(SESSION_LOGIN_FILE, 'utf-8');
+ } catch {
+ return undefined;
+ }
+}
+
+export async function getManagedAppTokenOrExit(token?: string): Promise<ManagedAppToken> {
+ if (token) {
+ return new ManagedLocalAppToken(token);
+ }
+ if (process.env.ASTRO_INTERNAL_TEST_REMOTE) {
+ return new ManagedLocalAppToken('fake' /* token ignored in test */);
+ }
+ const { ASTRO_STUDIO_APP_TOKEN } = getAstroStudioEnv();
+ if (ASTRO_STUDIO_APP_TOKEN) {
+ return new ManagedLocalAppToken(ASTRO_STUDIO_APP_TOKEN);
+ }
+ const sessionToken = await getSessionIdFromFile();
+ if (!sessionToken) {
+ if (ci.isCI) {
+ console.error(MISSING_SESSION_ID_CI_ERROR);
+ } else {
+ console.error(MISSING_SESSION_ID_ERROR);
+ }
+ process.exit(1);
+ }
+ const projectId = await getProjectIdFromFile();
+ if (!projectId) {
+ console.error(MISSING_PROJECT_ID_ERROR);
+ process.exit(1);
+ }
+ return ManagedRemoteAppToken.create(sessionToken, projectId);
+}
+
+function getExpiresFromTtl(ttl: number): Date {
+ // ttl is in minutes
+ return new Date(Date.now() + ttl * 60 * 1000);
+}
+
+/**
+ * Small wrapper around fetch that throws an error if the response is not OK. Allows for custom error handling as well through the onNotOK callback.
+ */
+async function safeFetch(
+ url: Parameters<typeof fetch>[0],
+ options: Parameters<typeof fetch>[1] = {},
+ onNotOK: (response: Response) => void | Promise<void> = () => {
+ throw new Error(`Request to ${url} returned a non-OK status code.`);
+ },
+): Promise<Response> {
+ const response = await fetch(url, options);
+
+ if (!response.ok) {
+ await onNotOK(response);
+ }
+
+ return response;
+}