Roberto Vidal commited on
Commit
44226db
·
unverified ·
1 Parent(s): b939a0a

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
- refresh: string;
12
- expiresAt: number;
13
- userId: string | null;
14
- segmentWriteKey: string | null;
15
  }
16
 
17
  export async function isAuthenticated(request: Request, env: Env) {
18
  const { session, sessionStorage } = await getSession(request, env);
19
- const token = session.get('refresh');
 
20
 
21
  const header = async (cookie: Promise<string>) => ({ headers: { 'Set-Cookie': await cookie } });
22
  const destroy = () => header(sessionStorage.destroySession(session));
23
 
24
- if (token == null) {
25
  return { authenticated: false as const, response: await destroy() };
26
  }
27
 
28
- const expiresAt = session.get('expiresAt') ?? 0;
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(token);
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
- session.set('expiresAt', expiresAt);
 
 
 
 
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
- session.set('refresh', tokens.refresh);
63
- session.set('expiresAt', expiresAt);
 
 
 
 
64
 
65
- if (identity) {
66
- session.set('userId', identity.userId ?? null);
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<SessionData>({
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
- revokeToken(session.get('refresh'));
 
 
 
 
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 getSession(request: Request, env: Env) {
 
 
 
 
 
 
 
 
 
 
 
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 { getSession } 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 { session } = await getSession(request, context.cloudflare.env);
9
- const { success, error } = await sendEventInternal(session.data, event);
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 { getSession } from '~/lib/.server/sessions';
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 { session } = await getSession(request, context.cloudflare.env);
25
 
26
- await sendEventInternal(session.data, {
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) >= levelOrder.indexOf(currentLevel)) {
48
- const labelBackgroundColor = getColorForLevel(level);
49
- const labelTextColor = level === 'warn' ? 'black' : 'white';
50
-
51
- const labelStyles = getLabelStyles(labelBackgroundColor, labelTextColor);
52
- const scopeStyles = getLabelStyles('#77828D', 'white');
53
 
54
- const styles = [labelStyles];
 
 
 
55
 
56
- if (typeof scope === 'string') {
57
- styles.push('', scopeStyles);
58
  }
59
 
60
- console.log(
61
- `%c${level.toUpperCase()}${scope ? `%c %c${scope}` : ''}`,
62
- ...styles,
63
- messages.reduce((acc, current) => {
64
- if (acc.endsWith('\n')) {
65
- return acc + current;
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
- LOGIN_PASSWORD: string;
5
  }
 
1
  interface Env {
2
  ANTHROPIC_API_KEY: string;
3
  SESSION_SECRET: string;
4
+ PAYLOAD_SECRET: string;
5
  }