aboutsummaryrefslogtreecommitdiff
path: root/web/ui/src/lib/server/auth.ts
blob: 6f202d307cf13f5561f91b8bbb0bfd2a44251937 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
import jwt from 'jsonwebtoken';
import { randomBytes } from 'node:crypto';
import { dev } from '$app/environment';
import { env } from '$env/dynamic/private';
import type { Cookies } from '@sveltejs/kit';

import { db, queries, newId, now, type Account } from './db';

function getJwtSecret(): string {
  const secret = env.JWT_SECRET;
  if (!secret) {
    throw new Error(
      'JWT_SECRET env variable is required. Copy .env.example to .env ' +
        'and generate one with `openssl rand -base64 48`.'
    );
  }
  return secret;
}

const COOKIE_NAME = 'ti_sess';
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year

interface SessionPayload {
  sub: string;              // account id
}

export function createSession(cookies: Cookies, accountId: string): void {
  const token = jwt.sign(
    { sub: accountId } satisfies SessionPayload,
    getJwtSecret(),
    { algorithm: 'HS256' }
  );
  cookies.set(COOKIE_NAME, token, {
    path: '/',
    httpOnly: true,
    sameSite: 'lax',
    secure: !dev,
    maxAge: COOKIE_MAX_AGE
  });
}

export function clearSession(cookies: Cookies): void {
  cookies.delete(COOKIE_NAME, { path: '/' });
}

export function getAccountFromCookies(cookies: Cookies): Account | null {
  const raw = cookies.get(COOKIE_NAME);
  if (!raw) return null;
  try {
    const decoded = jwt.verify(raw, getJwtSecret(), {
      algorithms: ['HS256']
    }) as SessionPayload;
    if (!decoded.sub) return null;
    const account = queries.accountById.get(decoded.sub);
    return account ?? null;
  } catch {
    return null;
  }
}

// -------- account helpers --------

/** Create a brand-new anonymous account (no email). */
export function createAnonymousAccount(): Account {
  const id = newId();
  const ts = now();
  queries.insertAccount.run(id, null, 'free', ts);
  return { id, email: null, pending_email: null, plan: 'free', created_at: ts };
}

/** Find an account by email or create a new one with that email. */
export function getOrCreateAccountByEmail(email: string): Account {
  const existing = queries.accountByEmail.get(email);
  if (existing) return existing;
  const id = newId();
  const ts = now();
  queries.insertAccount.run(id, email, 'free', ts);
  return { id, email, pending_email: null, plan: 'free', created_at: ts };
}

// -------- magic links --------

export interface MagicLink {
  token: string;
  expires_at: number;
  url: string;
}

export function generateMagicLink(
  accountId: string,
  baseUrl: string
): MagicLink {
  const token = randomBytes(24).toString('base64url');
  const id = newId();
  const ts = now();
  const expires = ts + 15 * 60 * 1000; // 15 minutes
  queries.insertMagicLink.run(id, accountId, token, expires, ts);
  const url = `${baseUrl.replace(/\/$/, '')}/auth/callback?token=${token}`;
  return { token, expires_at: expires, url };
}

export function consumeMagicLink(token: string): Account | null {
  const row = queries.magicLinkByToken.get(token);
  if (!row) return null;
  if (row.used === 1) return null;
  if (row.expires_at < now()) return null;
  queries.markMagicLinkUsed.run(row.id);
  const account = queries.accountById.get(row.account_id);
  return account ?? null;
}

// -------- email attach / merge --------

/**
 * Attach an email to an existing (anonymous) account. If another account
 * already uses that email, merge this account into the other one — all
 * API keys move over, the anonymous account is deleted, and the caller
 * receives the surviving account id (which may be different from the
 * one passed in).
 */
export function attachEmailToAccount(
  accountId: string,
  email: string
): { accountId: string; merged: boolean } {
  const normalized = email.trim().toLowerCase();
  if (!normalized) throw new Error('Email is empty');

  const current = queries.accountById.get(accountId);
  if (!current) throw new Error('Account not found');

  const existing = queries.accountByEmail.get(normalized);

  // Same account already has this email — no-op.
  if (existing && existing.id === current.id) {
    return { accountId: current.id, merged: false };
  }

  // No other account has this email — just attach it.
  if (!existing) {
    queries.updateAccountEmail.run(normalized, current.id);
    return { accountId: current.id, merged: false };
  }

  // Another account already owns the email. Merge current into existing:
  // move all keys over, then delete the current account.
  const merge = db.transaction(() => {
    queries.reassignKeys.run(existing.id, current.id);
    queries.deleteAccount.run(current.id);
  });
  merge();

  return { accountId: existing.id, merged: true };
}