2024-08-22 08:11:38 +00:00
|
|
|
import { createCookieSessionStorage, redirect, type Session as RemixSession } from '@remix-run/cloudflare';
|
2024-07-29 19:26:52 +00:00
|
|
|
import { decodeJwt } from 'jose';
|
2024-07-29 18:31:45 +00:00
|
|
|
import { CLIENT_ID, CLIENT_ORIGIN } from '~/lib/constants';
|
2024-07-30 08:31:35 +00:00
|
|
|
import { request as doRequest } from '~/lib/fetch';
|
2024-07-29 18:31:45 +00:00
|
|
|
import { logger } from '~/utils/logger';
|
2024-08-12 15:37:45 +00:00
|
|
|
import type { Identity } from '~/lib/analytics';
|
2024-08-19 15:39:37 +00:00
|
|
|
import { decrypt, encrypt } from '~/lib/crypto';
|
2024-07-11 19:25:19 +00:00
|
|
|
|
2024-07-29 18:31:45 +00:00
|
|
|
const DEV_SESSION_SECRET = import.meta.env.DEV ? 'LZQMrERo3Ewn/AbpSYJ9aw==' : undefined;
|
2024-08-19 15:39:37 +00:00
|
|
|
const DEV_PAYLOAD_SECRET = import.meta.env.DEV ? '2zAyrhjcdFeXk0YEDzilMXbdrGAiR+8ACIUgFNfjLaI=' : undefined;
|
|
|
|
|
|
|
|
const TOKEN_KEY = 't';
|
|
|
|
const EXPIRES_KEY = 'e';
|
|
|
|
const USER_ID_KEY = 'u';
|
|
|
|
const SEGMENT_KEY = 's';
|
2024-08-22 08:11:38 +00:00
|
|
|
const AVATAR_KEY = 'a';
|
|
|
|
const ENCRYPTED_KEY = 'd';
|
2024-07-11 19:25:19 +00:00
|
|
|
|
2024-08-22 08:11:38 +00:00
|
|
|
interface PrivateSession {
|
2024-08-19 15:39:37 +00:00
|
|
|
[TOKEN_KEY]: string;
|
|
|
|
[EXPIRES_KEY]: number;
|
|
|
|
[USER_ID_KEY]?: string;
|
|
|
|
[SEGMENT_KEY]?: string;
|
2024-07-29 18:31:45 +00:00
|
|
|
}
|
|
|
|
|
2024-08-22 08:11:38 +00:00
|
|
|
interface PublicSession {
|
|
|
|
[ENCRYPTED_KEY]: string;
|
|
|
|
[AVATAR_KEY]?: string;
|
|
|
|
}
|
|
|
|
|
|
|
|
export interface Session {
|
|
|
|
userId?: string;
|
|
|
|
segmentWriteKey?: string;
|
|
|
|
avatar?: string;
|
|
|
|
}
|
|
|
|
|
2024-07-29 18:31:45 +00:00
|
|
|
export async function isAuthenticated(request: Request, env: Env) {
|
|
|
|
const { session, sessionStorage } = await getSession(request, env);
|
2024-08-19 15:39:37 +00:00
|
|
|
|
2024-08-22 08:11:38 +00:00
|
|
|
const sessionData: PrivateSession | null = await decryptSessionData(env, session.get(ENCRYPTED_KEY));
|
2024-07-29 18:31:45 +00:00
|
|
|
|
|
|
|
const header = async (cookie: Promise<string>) => ({ headers: { 'Set-Cookie': await cookie } });
|
|
|
|
const destroy = () => header(sessionStorage.destroySession(session));
|
|
|
|
|
2024-08-19 15:39:37 +00:00
|
|
|
if (sessionData?.[TOKEN_KEY] == null) {
|
2024-08-22 08:11:38 +00:00
|
|
|
return { session: null, response: await destroy() };
|
2024-07-29 18:31:45 +00:00
|
|
|
}
|
|
|
|
|
2024-08-19 15:39:37 +00:00
|
|
|
const expiresAt = sessionData[EXPIRES_KEY] ?? 0;
|
2024-07-29 18:31:45 +00:00
|
|
|
|
|
|
|
if (Date.now() < expiresAt) {
|
2024-08-22 08:11:38 +00:00
|
|
|
return { session: getSessionData(session, sessionData) };
|
2024-07-29 18:31:45 +00:00
|
|
|
}
|
|
|
|
|
2024-08-19 15:39:37 +00:00
|
|
|
logger.debug('Renewing token');
|
|
|
|
|
2024-07-29 18:31:45 +00:00
|
|
|
let data: Awaited<ReturnType<typeof refreshToken>> | null = null;
|
|
|
|
|
|
|
|
try {
|
2024-08-19 15:39:37 +00:00
|
|
|
data = await refreshToken(sessionData[TOKEN_KEY]);
|
|
|
|
} catch (error) {
|
2024-07-30 08:31:35 +00:00
|
|
|
// we can ignore the error here because it's handled below
|
2024-08-19 15:39:37 +00:00
|
|
|
logger.error(error);
|
2024-07-29 18:31:45 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if (data != null) {
|
|
|
|
const expiresAt = cookieExpiration(data.expires_in, data.created_at);
|
2024-08-19 15:39:37 +00:00
|
|
|
|
|
|
|
const newSessionData = { ...sessionData, [EXPIRES_KEY]: expiresAt };
|
|
|
|
const encryptedData = await encryptSessionData(env, newSessionData);
|
|
|
|
|
2024-08-22 08:11:38 +00:00
|
|
|
session.set(ENCRYPTED_KEY, encryptedData);
|
2024-07-29 18:31:45 +00:00
|
|
|
|
2024-08-22 08:11:38 +00:00
|
|
|
return {
|
|
|
|
session: getSessionData(session, newSessionData),
|
|
|
|
response: await header(sessionStorage.commitSession(session)),
|
|
|
|
};
|
2024-07-29 18:31:45 +00:00
|
|
|
} else {
|
2024-08-22 08:11:38 +00:00
|
|
|
return { session: null, response: await destroy() };
|
2024-07-29 18:31:45 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
export async function createUserSession(
|
|
|
|
request: Request,
|
|
|
|
env: Env,
|
|
|
|
tokens: { refresh: string; expires_in: number; created_at: number },
|
2024-08-12 15:37:45 +00:00
|
|
|
identity?: Identity,
|
2024-07-29 18:31:45 +00:00
|
|
|
): Promise<ResponseInit> {
|
|
|
|
const { session, sessionStorage } = await getSession(request, env);
|
|
|
|
|
|
|
|
const expiresAt = cookieExpiration(tokens.expires_in, tokens.created_at);
|
|
|
|
|
2024-08-22 08:11:38 +00:00
|
|
|
const sessionData: PrivateSession = {
|
2024-08-19 15:39:37 +00:00
|
|
|
[TOKEN_KEY]: tokens.refresh,
|
|
|
|
[EXPIRES_KEY]: expiresAt,
|
|
|
|
[USER_ID_KEY]: identity?.userId ?? undefined,
|
|
|
|
[SEGMENT_KEY]: identity?.segmentWriteKey ?? undefined,
|
|
|
|
};
|
2024-07-29 18:31:45 +00:00
|
|
|
|
2024-08-19 15:39:37 +00:00
|
|
|
const encryptedData = await encryptSessionData(env, sessionData);
|
2024-08-22 08:11:38 +00:00
|
|
|
session.set(ENCRYPTED_KEY, encryptedData);
|
|
|
|
session.set(AVATAR_KEY, identity?.avatar);
|
2024-08-12 15:37:45 +00:00
|
|
|
|
2024-07-29 18:31:45 +00:00
|
|
|
return {
|
|
|
|
headers: {
|
|
|
|
'Set-Cookie': await sessionStorage.commitSession(session, {
|
|
|
|
maxAge: 3600 * 24 * 30, // 1 month
|
|
|
|
}),
|
|
|
|
},
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
function getSessionStorage(cloudflareEnv: Env) {
|
2024-08-22 08:11:38 +00:00
|
|
|
return createCookieSessionStorage<PublicSession>({
|
2024-07-11 19:25:19 +00:00
|
|
|
cookie: {
|
|
|
|
name: '__session',
|
|
|
|
httpOnly: true,
|
|
|
|
path: '/',
|
2024-07-29 18:31:45 +00:00
|
|
|
secrets: [DEV_SESSION_SECRET || cloudflareEnv.SESSION_SECRET],
|
|
|
|
secure: import.meta.env.PROD,
|
2024-07-11 19:25:19 +00:00
|
|
|
},
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
export async function logout(request: Request, env: Env) {
|
|
|
|
const { session, sessionStorage } = await getSession(request, env);
|
|
|
|
|
2024-08-22 08:11:38 +00:00
|
|
|
const sessionData = await decryptSessionData(env, session.get(ENCRYPTED_KEY));
|
2024-08-19 15:39:37 +00:00
|
|
|
|
|
|
|
if (sessionData) {
|
|
|
|
revokeToken(sessionData[TOKEN_KEY]);
|
|
|
|
}
|
2024-07-29 18:31:45 +00:00
|
|
|
|
2024-07-11 19:25:19 +00:00
|
|
|
return redirect('/login', {
|
|
|
|
headers: {
|
|
|
|
'Set-Cookie': await sessionStorage.destroySession(session),
|
|
|
|
},
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2024-07-29 18:31:45 +00:00
|
|
|
export function validateAccessToken(access: string) {
|
2024-07-29 19:26:52 +00:00
|
|
|
const jwtPayload = decodeJwt(access);
|
2024-07-29 18:31:45 +00:00
|
|
|
|
2024-07-29 19:26:52 +00:00
|
|
|
return jwtPayload.bolt === true;
|
2024-07-11 19:25:19 +00:00
|
|
|
}
|
|
|
|
|
2024-08-22 08:11:38 +00:00
|
|
|
function getSessionData(session: RemixSession<PublicSession>, data: PrivateSession): Session {
|
2024-08-19 15:39:37 +00:00
|
|
|
return {
|
2024-08-22 08:11:38 +00:00
|
|
|
userId: data?.[USER_ID_KEY],
|
|
|
|
segmentWriteKey: data?.[SEGMENT_KEY],
|
|
|
|
avatar: session.get(AVATAR_KEY),
|
2024-08-19 15:39:37 +00:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
async function getSession(request: Request, env: Env) {
|
2024-07-29 18:31:45 +00:00
|
|
|
const sessionStorage = getSessionStorage(env);
|
|
|
|
const cookie = request.headers.get('Cookie');
|
2024-07-11 19:25:19 +00:00
|
|
|
|
2024-07-29 18:31:45 +00:00
|
|
|
return { session: await sessionStorage.getSession(cookie), sessionStorage };
|
|
|
|
}
|
2024-07-11 19:25:19 +00:00
|
|
|
|
2024-07-29 18:31:45 +00:00
|
|
|
async function refreshToken(refresh: string): Promise<{ expires_in: number; created_at: number }> {
|
|
|
|
const response = await doRequest(`${CLIENT_ORIGIN}/oauth/token`, {
|
|
|
|
method: 'POST',
|
|
|
|
body: urlParams({ grant_type: 'refresh_token', client_id: CLIENT_ID, refresh_token: refresh }),
|
2024-08-19 15:39:37 +00:00
|
|
|
headers: {
|
|
|
|
'content-type': 'application/x-www-form-urlencoded',
|
|
|
|
},
|
2024-07-29 18:31:45 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
const body = await response.json();
|
|
|
|
|
|
|
|
if (!response.ok) {
|
2024-08-19 15:39:37 +00:00
|
|
|
throw new Error(`Unable to refresh token\n${response.status} ${JSON.stringify(body)}`);
|
2024-07-29 18:31:45 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
const { access_token: access } = body;
|
|
|
|
|
|
|
|
if (!validateAccessToken(access)) {
|
|
|
|
throw new Error('User is no longer authorized for Bolt');
|
|
|
|
}
|
|
|
|
|
|
|
|
return body;
|
|
|
|
}
|
|
|
|
|
|
|
|
function cookieExpiration(expireIn: number, createdAt: number) {
|
|
|
|
return (expireIn + createdAt - 10 * 60) * 1000;
|
|
|
|
}
|
|
|
|
|
|
|
|
async function revokeToken(refresh?: string) {
|
|
|
|
if (refresh == null) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
|
|
|
const response = await doRequest(`${CLIENT_ORIGIN}/oauth/revoke`, {
|
|
|
|
method: 'POST',
|
|
|
|
body: urlParams({
|
|
|
|
token: refresh,
|
|
|
|
token_type_hint: 'refresh_token',
|
|
|
|
client_id: CLIENT_ID,
|
2024-07-11 19:25:19 +00:00
|
|
|
}),
|
2024-08-19 15:39:37 +00:00
|
|
|
headers: {
|
|
|
|
'content-type': 'application/x-www-form-urlencoded',
|
|
|
|
},
|
2024-07-29 18:31:45 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
throw new Error(`Unable to revoke token: ${response.status}`);
|
|
|
|
}
|
|
|
|
} catch (error) {
|
|
|
|
logger.debug(error);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function urlParams(data: Record<string, string>) {
|
|
|
|
const encoded = new URLSearchParams();
|
|
|
|
|
|
|
|
for (const [key, value] of Object.entries(data)) {
|
|
|
|
encoded.append(key, value);
|
|
|
|
}
|
|
|
|
|
|
|
|
return encoded;
|
2024-07-11 19:25:19 +00:00
|
|
|
}
|
2024-08-19 15:39:37 +00:00
|
|
|
|
|
|
|
async function decryptSessionData(env: Env, encryptedData?: string) {
|
|
|
|
const decryptedData = encryptedData ? await decrypt(payloadSecret(env), encryptedData) : undefined;
|
2024-08-22 08:11:38 +00:00
|
|
|
const sessionData: PrivateSession | null = JSON.parse(decryptedData ?? 'null');
|
2024-08-19 15:39:37 +00:00
|
|
|
|
|
|
|
return sessionData;
|
|
|
|
}
|
|
|
|
|
2024-08-22 08:11:38 +00:00
|
|
|
async function encryptSessionData(env: Env, sessionData: PrivateSession) {
|
2024-08-19 15:39:37 +00:00
|
|
|
return await encrypt(payloadSecret(env), JSON.stringify(sessionData));
|
|
|
|
}
|
|
|
|
|
|
|
|
function payloadSecret(env: Env) {
|
|
|
|
return DEV_PAYLOAD_SECRET || env.PAYLOAD_SECRET;
|
|
|
|
}
|