import type { Models } from '@kittycad/lib' import { VITE_KC_API_BASE_URL, VITE_KC_DEV_TOKEN } from '@src/env' import { assign, fromPromise, setup } from 'xstate' import { COOKIE_NAME, OAUTH2_DEVICE_CLIENT_ID } from '@src/lib/constants' import { getUser as getUserDesktop, readTokenFile, writeTokenFile, } from '@src/lib/desktop' import { isDesktop } from '@src/lib/isDesktop' import { markOnce } from '@src/lib/performance' import { default as withBaseURL, default as withBaseUrl, } from '@src/lib/withBaseURL' import { ACTOR_IDS } from '@src/machines/machineConstants' export interface UserContext { user?: Models['User_type'] token: string } export type Events = | { type: 'Log out' } | { type: 'Log in' token?: string } export const TOKEN_PERSIST_KEY = 'TOKEN_PERSIST_KEY' export const persistedToken = VITE_KC_DEV_TOKEN || getCookie(COOKIE_NAME) || localStorage?.getItem(TOKEN_PERSIST_KEY) || '' export const authMachine = setup({ types: {} as { context: UserContext events: | Events | { type: 'xstate.done.actor.check-logged-in' output: { user: Models['User_type'] token: string } } }, actors: { getUser: fromPromise(({ input }: { input: { token?: string } }) => getUser(input) ), logout: fromPromise(logout), }, }).createMachine({ /** @xstate-layout N4IgpgJg5mDOIC5QEECuAXAFgOgMabFwGsBJAMwBkB7KGCEgOwGIIqGxsBLBgNyqI75CRALQAbGnRHcA2gAYAuolAAHKrE7pObZSAAeiAIwAWQ9gBspuQCYAnAGYAHPYCsx+4ccAaEAE9E1q7YcoZyxrYR1m7mcrYAvnE+aFh4BMTk1LSQjExgAE55VHnYKmIAhuhkRQC2qcLikpDSDPJKSCBqGlo67QYI9gDs5tge5o6h5vau7oY+-v3mA9jWco4u5iu21ua2YcYJSRg4Eln0zJkABFQYrbqdmtoMun2GA7YjxuPmLqvGNh5zRCfJaOcyLUzuAYuFyGcwHEDJY6NCAAeQwTEuskUd3UDx6oD6Im2wUcAzkMJ2cjBxlMgIWLmwZLWljecjJTjh8IYVAgcF0iJxXUez0QIgGxhJZIpu2ptL8AWwtje1nCW2iq1shns8MRdXSlGRjEFeKevUQjkcy3sqwGHimbg83nlCF22GMytVUWMMUc8USCKO2BOdCN7Xu3VNBKMKsVFp2hm2vu+1id83slkVrgTxhcW0pNJ1geDkDR6GNEZFCAT1kZZLk9cMLltb0WdPMjewjjC1mzOZCtk5CSAA */ id: ACTOR_IDS.AUTH, initial: 'checkIfLoggedIn', context: { token: persistedToken, }, states: { checkIfLoggedIn: { id: 'check-if-logged-in', invoke: { src: 'getUser', input: ({ context }) => ({ token: context.token }), id: 'check-logged-in', onDone: [ { target: 'loggedIn', actions: assign(({ context, event }) => ({ user: event.output.user, token: event.output.token || context.token, })), }, ], onError: [ { target: 'loggedOut', actions: assign({ user: () => undefined, }), }, ], }, }, loggedIn: { on: { 'Log out': { target: 'loggingOut', }, }, }, loggingOut: { invoke: { src: 'logout', onDone: 'loggedOut', onError: { target: 'loggedIn', actions: [ ({ event }) => { console.error( 'Error while logging out', 'error' in event ? `: ${event.error}` : '' ) }, ], }, }, }, loggedOut: { on: { 'Log in': { target: 'checkIfLoggedIn', actions: assign({ token: ({ event }) => { const token = event.token || '' return token }, }), }, }, }, }, schema: { events: {} as { type: 'Log out' } | { type: 'Log in' } }, }) async function getUser(input: { token?: string }) { const token = await getAndSyncStoredToken(input) const url = withBaseURL('/user') const headers: { [key: string]: string } = { 'Content-Type': 'application/json', } if (!token && isDesktop()) return Promise.reject(new Error('No token found')) if (token) headers['Authorization'] = `Bearer ${token}` const userPromise = isDesktop() ? getUserDesktop(token, VITE_KC_API_BASE_URL) : fetch(url, { method: 'GET', credentials: 'include', headers, }) .then((res) => res.json()) .catch((err) => console.error('error from Browser getUser', err)) const user = await userPromise // Necessary here because we use Kurt's API key in CI if (localStorage.getItem('FORCE_NO_IMAGE')) { user.image = '' } if ('error_code' in user) return Promise.reject(new Error(user.message)) markOnce('code/didAuth') return { user: user as Models['User_type'], token, } } export function getCookie(cname: string): string | null { if (isDesktop()) { return null } let name = cname + '=' let decodedCookie = decodeURIComponent(document.cookie) let ca = decodedCookie.split(';') for (let i = 0; i < ca.length; i++) { let c = ca[i] while (c.charAt(0) === ' ') { c = c.substring(1) } if (c.indexOf(name) === 0) { return c.substring(name.length, c.length) } } return null } async function getAndSyncStoredToken(input: { token?: string }): Promise { // dev mode if (VITE_KC_DEV_TOKEN) return VITE_KC_DEV_TOKEN const token = input.token && input.token !== '' ? input.token : getCookie(COOKIE_NAME) || localStorage?.getItem(TOKEN_PERSIST_KEY) || '' if (token) { // has just logged in, update storage localStorage.setItem(TOKEN_PERSIST_KEY, token) if (isDesktop()) { await writeTokenFile(token) } return token } if (!isDesktop()) return '' const fileToken = isDesktop() ? await readTokenFile() : '' // prefer other above, but file will ensure login persists after app updates if (!fileToken) return '' // has token in file, update localStorage localStorage.setItem(TOKEN_PERSIST_KEY, fileToken) return fileToken } async function logout() { localStorage.removeItem(TOKEN_PERSIST_KEY) if (isDesktop()) { try { let token = await readTokenFile() if (token) { try { await fetch(withBaseUrl('/oauth2/token/revoke'), { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ token: token, client_id: OAUTH2_DEVICE_CLIENT_ID, }).toString(), }) } catch (e) { console.error('Error revoking token:', e) } await writeTokenFile('') return Promise.resolve(null) } } catch (e) { console.error('Error reading token during logout (ignoring):', e) } } return fetch(withBaseUrl('/logout'), { method: 'POST', credentials: 'include', }) }