Roberto Vidal
commited on
feat(session): encrypt data and fix renewal (#38)
Browse files
packages/bolt/app/lib/.server/sessions.ts
CHANGED
@@ -4,44 +4,59 @@ import { CLIENT_ID, CLIENT_ORIGIN } from '~/lib/constants';
|
|
4 |
import { request as doRequest } from '~/lib/fetch';
|
5 |
import { logger } from '~/utils/logger';
|
6 |
import type { Identity } from '~/lib/analytics';
|
|
|
7 |
|
8 |
const DEV_SESSION_SECRET = import.meta.env.DEV ? 'LZQMrERo3Ewn/AbpSYJ9aw==' : undefined;
|
|
|
|
|
|
|
|
|
|
|
|
|
9 |
|
10 |
interface SessionData {
|
11 |
-
|
12 |
-
|
13 |
-
|
14 |
-
|
15 |
}
|
16 |
|
17 |
export async function isAuthenticated(request: Request, env: Env) {
|
18 |
const { session, sessionStorage } = await getSession(request, env);
|
19 |
-
|
|
|
20 |
|
21 |
const header = async (cookie: Promise<string>) => ({ headers: { 'Set-Cookie': await cookie } });
|
22 |
const destroy = () => header(sessionStorage.destroySession(session));
|
23 |
|
24 |
-
if (
|
25 |
return { authenticated: false as const, response: await destroy() };
|
26 |
}
|
27 |
|
28 |
-
const expiresAt =
|
29 |
|
30 |
if (Date.now() < expiresAt) {
|
31 |
return { authenticated: true as const };
|
32 |
}
|
33 |
|
|
|
|
|
34 |
let data: Awaited<ReturnType<typeof refreshToken>> | null = null;
|
35 |
|
36 |
try {
|
37 |
-
data = await refreshToken(
|
38 |
-
} catch {
|
39 |
// we can ignore the error here because it's handled below
|
|
|
40 |
}
|
41 |
|
42 |
if (data != null) {
|
43 |
const expiresAt = cookieExpiration(data.expires_in, data.created_at);
|
44 |
-
|
|
|
|
|
|
|
|
|
45 |
|
46 |
return { authenticated: true as const, response: await header(sessionStorage.commitSession(session)) };
|
47 |
} else {
|
@@ -59,13 +74,15 @@ export async function createUserSession(
|
|
59 |
|
60 |
const expiresAt = cookieExpiration(tokens.expires_in, tokens.created_at);
|
61 |
|
62 |
-
|
63 |
-
|
|
|
|
|
|
|
|
|
64 |
|
65 |
-
|
66 |
-
|
67 |
-
session.set('segmentWriteKey', identity.segmentWriteKey ?? null);
|
68 |
-
}
|
69 |
|
70 |
return {
|
71 |
headers: {
|
@@ -77,7 +94,7 @@ export async function createUserSession(
|
|
77 |
}
|
78 |
|
79 |
function getSessionStorage(cloudflareEnv: Env) {
|
80 |
-
return createCookieSessionStorage<
|
81 |
cookie: {
|
82 |
name: '__session',
|
83 |
httpOnly: true,
|
@@ -91,7 +108,11 @@ function getSessionStorage(cloudflareEnv: Env) {
|
|
91 |
export async function logout(request: Request, env: Env) {
|
92 |
const { session, sessionStorage } = await getSession(request, env);
|
93 |
|
94 |
-
|
|
|
|
|
|
|
|
|
95 |
|
96 |
return redirect('/login', {
|
97 |
headers: {
|
@@ -106,7 +127,18 @@ export function validateAccessToken(access: string) {
|
|
106 |
return jwtPayload.bolt === true;
|
107 |
}
|
108 |
|
109 |
-
export async function
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
110 |
const sessionStorage = getSessionStorage(env);
|
111 |
const cookie = request.headers.get('Cookie');
|
112 |
|
@@ -117,12 +149,15 @@ async function refreshToken(refresh: string): Promise<{ expires_in: number; crea
|
|
117 |
const response = await doRequest(`${CLIENT_ORIGIN}/oauth/token`, {
|
118 |
method: 'POST',
|
119 |
body: urlParams({ grant_type: 'refresh_token', client_id: CLIENT_ID, refresh_token: refresh }),
|
|
|
|
|
|
|
120 |
});
|
121 |
|
122 |
const body = await response.json();
|
123 |
|
124 |
if (!response.ok) {
|
125 |
-
throw new Error(`Unable to refresh token\n${JSON.stringify(body)}`);
|
126 |
}
|
127 |
|
128 |
const { access_token: access } = body;
|
@@ -151,6 +186,9 @@ async function revokeToken(refresh?: string) {
|
|
151 |
token_type_hint: 'refresh_token',
|
152 |
client_id: CLIENT_ID,
|
153 |
}),
|
|
|
|
|
|
|
154 |
});
|
155 |
|
156 |
if (!response.ok) {
|
@@ -171,3 +209,18 @@ function urlParams(data: Record<string, string>) {
|
|
171 |
|
172 |
return encoded;
|
173 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
4 |
import { request as doRequest } from '~/lib/fetch';
|
5 |
import { logger } from '~/utils/logger';
|
6 |
import type { Identity } from '~/lib/analytics';
|
7 |
+
import { decrypt, encrypt } from '~/lib/crypto';
|
8 |
|
9 |
const DEV_SESSION_SECRET = import.meta.env.DEV ? 'LZQMrERo3Ewn/AbpSYJ9aw==' : undefined;
|
10 |
+
const DEV_PAYLOAD_SECRET = import.meta.env.DEV ? '2zAyrhjcdFeXk0YEDzilMXbdrGAiR+8ACIUgFNfjLaI=' : undefined;
|
11 |
+
|
12 |
+
const TOKEN_KEY = 't';
|
13 |
+
const EXPIRES_KEY = 'e';
|
14 |
+
const USER_ID_KEY = 'u';
|
15 |
+
const SEGMENT_KEY = 's';
|
16 |
|
17 |
interface SessionData {
|
18 |
+
[TOKEN_KEY]: string;
|
19 |
+
[EXPIRES_KEY]: number;
|
20 |
+
[USER_ID_KEY]?: string;
|
21 |
+
[SEGMENT_KEY]?: string;
|
22 |
}
|
23 |
|
24 |
export async function isAuthenticated(request: Request, env: Env) {
|
25 |
const { session, sessionStorage } = await getSession(request, env);
|
26 |
+
|
27 |
+
const sessionData: SessionData | null = await decryptSessionData(env, session.get('d'));
|
28 |
|
29 |
const header = async (cookie: Promise<string>) => ({ headers: { 'Set-Cookie': await cookie } });
|
30 |
const destroy = () => header(sessionStorage.destroySession(session));
|
31 |
|
32 |
+
if (sessionData?.[TOKEN_KEY] == null) {
|
33 |
return { authenticated: false as const, response: await destroy() };
|
34 |
}
|
35 |
|
36 |
+
const expiresAt = sessionData[EXPIRES_KEY] ?? 0;
|
37 |
|
38 |
if (Date.now() < expiresAt) {
|
39 |
return { authenticated: true as const };
|
40 |
}
|
41 |
|
42 |
+
logger.debug('Renewing token');
|
43 |
+
|
44 |
let data: Awaited<ReturnType<typeof refreshToken>> | null = null;
|
45 |
|
46 |
try {
|
47 |
+
data = await refreshToken(sessionData[TOKEN_KEY]);
|
48 |
+
} catch (error) {
|
49 |
// we can ignore the error here because it's handled below
|
50 |
+
logger.error(error);
|
51 |
}
|
52 |
|
53 |
if (data != null) {
|
54 |
const expiresAt = cookieExpiration(data.expires_in, data.created_at);
|
55 |
+
|
56 |
+
const newSessionData = { ...sessionData, [EXPIRES_KEY]: expiresAt };
|
57 |
+
const encryptedData = await encryptSessionData(env, newSessionData);
|
58 |
+
|
59 |
+
session.set('d', encryptedData);
|
60 |
|
61 |
return { authenticated: true as const, response: await header(sessionStorage.commitSession(session)) };
|
62 |
} else {
|
|
|
74 |
|
75 |
const expiresAt = cookieExpiration(tokens.expires_in, tokens.created_at);
|
76 |
|
77 |
+
const sessionData: SessionData = {
|
78 |
+
[TOKEN_KEY]: tokens.refresh,
|
79 |
+
[EXPIRES_KEY]: expiresAt,
|
80 |
+
[USER_ID_KEY]: identity?.userId ?? undefined,
|
81 |
+
[SEGMENT_KEY]: identity?.segmentWriteKey ?? undefined,
|
82 |
+
};
|
83 |
|
84 |
+
const encryptedData = await encryptSessionData(env, sessionData);
|
85 |
+
session.set('d', encryptedData);
|
|
|
|
|
86 |
|
87 |
return {
|
88 |
headers: {
|
|
|
94 |
}
|
95 |
|
96 |
function getSessionStorage(cloudflareEnv: Env) {
|
97 |
+
return createCookieSessionStorage<{ d: string }>({
|
98 |
cookie: {
|
99 |
name: '__session',
|
100 |
httpOnly: true,
|
|
|
108 |
export async function logout(request: Request, env: Env) {
|
109 |
const { session, sessionStorage } = await getSession(request, env);
|
110 |
|
111 |
+
const sessionData = await decryptSessionData(env, session.get('d'));
|
112 |
+
|
113 |
+
if (sessionData) {
|
114 |
+
revokeToken(sessionData[TOKEN_KEY]);
|
115 |
+
}
|
116 |
|
117 |
return redirect('/login', {
|
118 |
headers: {
|
|
|
127 |
return jwtPayload.bolt === true;
|
128 |
}
|
129 |
|
130 |
+
export async function getSessionData(request: Request, env: Env) {
|
131 |
+
const { session } = await getSession(request, env);
|
132 |
+
|
133 |
+
const decrypted = await decryptSessionData(env, session.get('d'));
|
134 |
+
|
135 |
+
return {
|
136 |
+
userId: decrypted?.[USER_ID_KEY],
|
137 |
+
segmentWriteKey: decrypted?.[SEGMENT_KEY],
|
138 |
+
};
|
139 |
+
}
|
140 |
+
|
141 |
+
async function getSession(request: Request, env: Env) {
|
142 |
const sessionStorage = getSessionStorage(env);
|
143 |
const cookie = request.headers.get('Cookie');
|
144 |
|
|
|
149 |
const response = await doRequest(`${CLIENT_ORIGIN}/oauth/token`, {
|
150 |
method: 'POST',
|
151 |
body: urlParams({ grant_type: 'refresh_token', client_id: CLIENT_ID, refresh_token: refresh }),
|
152 |
+
headers: {
|
153 |
+
'content-type': 'application/x-www-form-urlencoded',
|
154 |
+
},
|
155 |
});
|
156 |
|
157 |
const body = await response.json();
|
158 |
|
159 |
if (!response.ok) {
|
160 |
+
throw new Error(`Unable to refresh token\n${response.status} ${JSON.stringify(body)}`);
|
161 |
}
|
162 |
|
163 |
const { access_token: access } = body;
|
|
|
186 |
token_type_hint: 'refresh_token',
|
187 |
client_id: CLIENT_ID,
|
188 |
}),
|
189 |
+
headers: {
|
190 |
+
'content-type': 'application/x-www-form-urlencoded',
|
191 |
+
},
|
192 |
});
|
193 |
|
194 |
if (!response.ok) {
|
|
|
209 |
|
210 |
return encoded;
|
211 |
}
|
212 |
+
|
213 |
+
async function decryptSessionData(env: Env, encryptedData?: string) {
|
214 |
+
const decryptedData = encryptedData ? await decrypt(payloadSecret(env), encryptedData) : undefined;
|
215 |
+
const sessionData: SessionData | null = JSON.parse(decryptedData ?? 'null');
|
216 |
+
|
217 |
+
return sessionData;
|
218 |
+
}
|
219 |
+
|
220 |
+
async function encryptSessionData(env: Env, sessionData: SessionData) {
|
221 |
+
return await encrypt(payloadSecret(env), JSON.stringify(sessionData));
|
222 |
+
}
|
223 |
+
|
224 |
+
function payloadSecret(env: Env) {
|
225 |
+
return DEV_PAYLOAD_SECRET || env.PAYLOAD_SECRET;
|
226 |
+
}
|
packages/bolt/app/lib/crypto.ts
ADDED
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const encoder = new TextEncoder();
|
2 |
+
const decoder = new TextDecoder();
|
3 |
+
const IV_LENGTH = 16;
|
4 |
+
|
5 |
+
export async function encrypt(key: string, data: string) {
|
6 |
+
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
7 |
+
const cryptoKey = await getKey(key);
|
8 |
+
|
9 |
+
const ciphertext = await crypto.subtle.encrypt(
|
10 |
+
{
|
11 |
+
name: 'AES-CBC',
|
12 |
+
iv,
|
13 |
+
},
|
14 |
+
cryptoKey,
|
15 |
+
encoder.encode(data),
|
16 |
+
);
|
17 |
+
|
18 |
+
const bundle = new Uint8Array(IV_LENGTH + ciphertext.byteLength);
|
19 |
+
|
20 |
+
bundle.set(new Uint8Array(ciphertext));
|
21 |
+
bundle.set(iv, ciphertext.byteLength);
|
22 |
+
|
23 |
+
return decodeBase64(bundle);
|
24 |
+
}
|
25 |
+
|
26 |
+
export async function decrypt(key: string, payload: string) {
|
27 |
+
const bundle = encodeBase64(payload);
|
28 |
+
|
29 |
+
const iv = new Uint8Array(bundle.buffer, bundle.byteLength - IV_LENGTH);
|
30 |
+
const ciphertext = new Uint8Array(bundle.buffer, 0, bundle.byteLength - IV_LENGTH);
|
31 |
+
|
32 |
+
const cryptoKey = await getKey(key);
|
33 |
+
|
34 |
+
const plaintext = await crypto.subtle.decrypt(
|
35 |
+
{
|
36 |
+
name: 'AES-CBC',
|
37 |
+
iv,
|
38 |
+
},
|
39 |
+
cryptoKey,
|
40 |
+
ciphertext,
|
41 |
+
);
|
42 |
+
|
43 |
+
return decoder.decode(plaintext);
|
44 |
+
}
|
45 |
+
|
46 |
+
async function getKey(key: string) {
|
47 |
+
return await crypto.subtle.importKey('raw', encodeBase64(key), { name: 'AES-CBC' }, false, ['encrypt', 'decrypt']);
|
48 |
+
}
|
49 |
+
|
50 |
+
function decodeBase64(encoded: Uint8Array) {
|
51 |
+
const byteChars = Array.from(encoded, (byte) => String.fromCodePoint(byte));
|
52 |
+
|
53 |
+
return btoa(byteChars.join(''));
|
54 |
+
}
|
55 |
+
|
56 |
+
function encodeBase64(data: string) {
|
57 |
+
return Uint8Array.from(atob(data), (ch) => ch.codePointAt(0)!);
|
58 |
+
}
|
packages/bolt/app/routes/api.analytics.ts
CHANGED
@@ -1,12 +1,12 @@
|
|
1 |
import { json, type ActionFunctionArgs } from '@remix-run/cloudflare';
|
2 |
import { handleWithAuth } from '~/lib/.server/login';
|
3 |
-
import {
|
4 |
import { sendEventInternal, type AnalyticsEvent } from '~/lib/analytics';
|
5 |
|
6 |
async function analyticsAction({ request, context }: ActionFunctionArgs) {
|
7 |
const event: AnalyticsEvent = await request.json();
|
8 |
-
const
|
9 |
-
const { success, error } = await sendEventInternal(
|
10 |
|
11 |
if (!success) {
|
12 |
return json({ error }, { status: 500 });
|
|
|
1 |
import { json, type ActionFunctionArgs } from '@remix-run/cloudflare';
|
2 |
import { handleWithAuth } from '~/lib/.server/login';
|
3 |
+
import { getSessionData } from '~/lib/.server/sessions';
|
4 |
import { sendEventInternal, type AnalyticsEvent } from '~/lib/analytics';
|
5 |
|
6 |
async function analyticsAction({ request, context }: ActionFunctionArgs) {
|
7 |
const event: AnalyticsEvent = await request.json();
|
8 |
+
const sessionData = await getSessionData(request, context.cloudflare.env);
|
9 |
+
const { success, error } = await sendEventInternal(sessionData, event);
|
10 |
|
11 |
if (!success) {
|
12 |
return json({ error }, { status: 500 });
|
packages/bolt/app/routes/api.chat.ts
CHANGED
@@ -4,7 +4,7 @@ import { CONTINUE_PROMPT } from '~/lib/.server/llm/prompts';
|
|
4 |
import { streamText, type Messages, type StreamingOptions } from '~/lib/.server/llm/stream-text';
|
5 |
import SwitchableStream from '~/lib/.server/llm/switchable-stream';
|
6 |
import { handleWithAuth } from '~/lib/.server/login';
|
7 |
-
import {
|
8 |
import { AnalyticsAction, AnalyticsTrackEvent, sendEventInternal } from '~/lib/analytics';
|
9 |
|
10 |
export async function action(args: ActionFunctionArgs) {
|
@@ -21,9 +21,9 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
|
|
21 |
toolChoice: 'none',
|
22 |
onFinish: async ({ text: content, finishReason, usage }) => {
|
23 |
if (finishReason !== 'length') {
|
24 |
-
const
|
25 |
|
26 |
-
await sendEventInternal(
|
27 |
action: AnalyticsAction.Track,
|
28 |
payload: {
|
29 |
event: AnalyticsTrackEvent.MessageComplete,
|
|
|
4 |
import { streamText, type Messages, type StreamingOptions } from '~/lib/.server/llm/stream-text';
|
5 |
import SwitchableStream from '~/lib/.server/llm/switchable-stream';
|
6 |
import { handleWithAuth } from '~/lib/.server/login';
|
7 |
+
import { getSessionData } from '~/lib/.server/sessions';
|
8 |
import { AnalyticsAction, AnalyticsTrackEvent, sendEventInternal } from '~/lib/analytics';
|
9 |
|
10 |
export async function action(args: ActionFunctionArgs) {
|
|
|
21 |
toolChoice: 'none',
|
22 |
onFinish: async ({ text: content, finishReason, usage }) => {
|
23 |
if (finishReason !== 'length') {
|
24 |
+
const sessionData = await getSessionData(request, context.cloudflare.env);
|
25 |
|
26 |
+
await sendEventInternal(sessionData, {
|
27 |
action: AnalyticsAction.Track,
|
28 |
payload: {
|
29 |
event: AnalyticsTrackEvent.MessageComplete,
|
packages/bolt/app/utils/logger.ts
CHANGED
@@ -13,6 +13,9 @@ interface Logger {
|
|
13 |
|
14 |
let currentLevel: DebugLevel = import.meta.env.VITE_LOG_LEVEL ?? import.meta.env.DEV ? 'debug' : 'info';
|
15 |
|
|
|
|
|
|
|
16 |
export const logger: Logger = {
|
17 |
trace: (...messages: any[]) => log('trace', undefined, messages),
|
18 |
debug: (...messages: any[]) => log('debug', undefined, messages),
|
@@ -44,35 +47,41 @@ function setLevel(level: DebugLevel) {
|
|
44 |
function log(level: DebugLevel, scope: string | undefined, messages: any[]) {
|
45 |
const levelOrder: DebugLevel[] = ['trace', 'debug', 'info', 'warn', 'error'];
|
46 |
|
47 |
-
if (levelOrder.indexOf(level)
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
const labelStyles = getLabelStyles(labelBackgroundColor, labelTextColor);
|
52 |
-
const scopeStyles = getLabelStyles('#77828D', 'white');
|
53 |
|
54 |
-
|
|
|
|
|
|
|
55 |
|
56 |
-
if (
|
57 |
-
|
58 |
}
|
59 |
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
|
67 |
-
|
68 |
-
if (!acc) {
|
69 |
-
return current;
|
70 |
-
}
|
71 |
-
|
72 |
-
return `${acc} ${current}`;
|
73 |
-
}, ''),
|
74 |
-
);
|
75 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
76 |
}
|
77 |
|
78 |
function getLabelStyles(color: string, textColor: string) {
|
|
|
13 |
|
14 |
let currentLevel: DebugLevel = import.meta.env.VITE_LOG_LEVEL ?? import.meta.env.DEV ? 'debug' : 'info';
|
15 |
|
16 |
+
const isWorker = 'HTMLRewriter' in globalThis;
|
17 |
+
const supportsColor = !isWorker;
|
18 |
+
|
19 |
export const logger: Logger = {
|
20 |
trace: (...messages: any[]) => log('trace', undefined, messages),
|
21 |
debug: (...messages: any[]) => log('debug', undefined, messages),
|
|
|
47 |
function log(level: DebugLevel, scope: string | undefined, messages: any[]) {
|
48 |
const levelOrder: DebugLevel[] = ['trace', 'debug', 'info', 'warn', 'error'];
|
49 |
|
50 |
+
if (levelOrder.indexOf(level) < levelOrder.indexOf(currentLevel)) {
|
51 |
+
return;
|
52 |
+
}
|
|
|
|
|
|
|
53 |
|
54 |
+
const allMessages = messages.reduce((acc, current) => {
|
55 |
+
if (acc.endsWith('\n')) {
|
56 |
+
return acc + current;
|
57 |
+
}
|
58 |
|
59 |
+
if (!acc) {
|
60 |
+
return current;
|
61 |
}
|
62 |
|
63 |
+
return `${acc} ${current}`;
|
64 |
+
}, '');
|
65 |
+
|
66 |
+
if (!supportsColor) {
|
67 |
+
console.log(`[${level.toUpperCase()}]`, allMessages);
|
68 |
+
|
69 |
+
return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
70 |
}
|
71 |
+
|
72 |
+
const labelBackgroundColor = getColorForLevel(level);
|
73 |
+
const labelTextColor = level === 'warn' ? 'black' : 'white';
|
74 |
+
|
75 |
+
const labelStyles = getLabelStyles(labelBackgroundColor, labelTextColor);
|
76 |
+
const scopeStyles = getLabelStyles('#77828D', 'white');
|
77 |
+
|
78 |
+
const styles = [labelStyles];
|
79 |
+
|
80 |
+
if (typeof scope === 'string') {
|
81 |
+
styles.push('', scopeStyles);
|
82 |
+
}
|
83 |
+
|
84 |
+
console.log(`%c${level.toUpperCase()}${scope ? `%c %c${scope}` : ''}`, ...styles, allMessages);
|
85 |
}
|
86 |
|
87 |
function getLabelStyles(color: string, textColor: string) {
|
packages/bolt/worker-configuration.d.ts
CHANGED
@@ -1,5 +1,5 @@
|
|
1 |
interface Env {
|
2 |
ANTHROPIC_API_KEY: string;
|
3 |
SESSION_SECRET: string;
|
4 |
-
|
5 |
}
|
|
|
1 |
interface Env {
|
2 |
ANTHROPIC_API_KEY: string;
|
3 |
SESSION_SECRET: string;
|
4 |
+
PAYLOAD_SECRET: string;
|
5 |
}
|