This commit is contained in:
Michael Freno
2026-01-07 20:19:26 -05:00
parent 244c8b6fb5
commit f056cf05d3
21 changed files with 2407 additions and 168 deletions

View File

@@ -30,6 +30,11 @@ export const model: { [key: string]: string } = {
ip_address TEXT,
user_agent TEXT,
revoked INTEGER DEFAULT 0,
device_name TEXT,
device_type TEXT,
browser TEXT,
os TEXT,
last_active_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE,
FOREIGN KEY (parent_session_id) REFERENCES Session(id) ON DELETE SET NULL
);
@@ -38,6 +43,28 @@ export const model: { [key: string]: string } = {
CREATE INDEX IF NOT EXISTS idx_session_token_family ON Session (token_family);
CREATE INDEX IF NOT EXISTS idx_session_refresh_token_hash ON Session (refresh_token_hash);
CREATE INDEX IF NOT EXISTS idx_session_revoked ON Session (revoked);
CREATE INDEX IF NOT EXISTS idx_session_last_active ON Session (last_active_at);
CREATE INDEX IF NOT EXISTS idx_session_user_active ON Session (user_id, revoked, last_active_at);
`,
UserProvider: `
CREATE TABLE UserProvider
(
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
provider TEXT NOT NULL CHECK(provider IN ('email', 'google', 'github', 'apple')),
provider_user_id TEXT,
email TEXT,
display_name TEXT,
image TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_used_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_provider_provider_user ON UserProvider (provider, provider_user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_provider_provider_email ON UserProvider (provider, email);
CREATE INDEX IF NOT EXISTS idx_user_provider_user_id ON UserProvider (user_id);
CREATE INDEX IF NOT EXISTS idx_user_provider_provider ON UserProvider (provider);
CREATE INDEX IF NOT EXISTS idx_user_provider_email ON UserProvider (email);
`,
PasswordResetToken: `
CREATE TABLE PasswordResetToken

View File

@@ -27,6 +27,23 @@ export interface Session {
ip_address?: string | null;
user_agent?: string | null;
revoked: number;
device_name?: string | null;
device_type?: string | null;
browser?: string | null;
os?: string | null;
last_active_at?: string | null;
}
export interface UserProvider {
id: string;
user_id: string;
provider: "email" | "google" | "github" | "apple"; // apple is for Life and Lineage mobile app only
provider_user_id?: string | null;
email?: string | null;
display_name?: string | null;
image?: string | null;
created_at: string;
last_used_at: string;
}
export interface PasswordResetToken {

View File

@@ -1,4 +1,4 @@
import { createSignal, Show, createEffect } from "solid-js";
import { createSignal, Show, createEffect, For } from "solid-js";
import { PageHead } from "~/components/PageHead";
import { useNavigate, redirect, query, createAsync } from "@solidjs/router";
import XCircle from "~/components/icons/XCircle";
@@ -858,6 +858,30 @@ export default function AccountPage() {
<hr class="mt-8 mb-8" />
{/* Linked Providers Section */}
<div class="mx-auto max-w-2xl py-8">
<div class="mb-6 text-center text-2xl font-semibold">
Linked Authentication Methods
</div>
<div class="bg-surface0 border-surface1 rounded-lg border px-6 py-4 shadow-sm">
<LinkedProviders userId={userProfile().id} />
</div>
</div>
<hr class="mt-8 mb-8" />
{/* Active Sessions Section */}
<div class="mx-auto max-w-2xl py-8">
<div class="mb-6 text-center text-2xl font-semibold">
Active Sessions
</div>
<div class="bg-surface0 border-surface1 rounded-lg border px-6 py-4 shadow-sm">
<ActiveSessions userId={userProfile().id} />
</div>
</div>
<hr class="mt-8 mb-8" />
{/* Sign Out Section */}
<div class="mx-auto max-w-md py-4">
<Button
@@ -950,3 +974,265 @@ export default function AccountPage() {
</>
);
}
function LinkedProviders(props: { userId: string }) {
const [providers, setProviders] = createSignal<any[]>([]);
const [loading, setLoading] = createSignal(true);
const [unlinkLoading, setUnlinkLoading] = createSignal<string | null>(null);
const loadProviders = async () => {
try {
const response = await fetch("/api/trpc/user.getProviders");
const result = await response.json();
if (response.ok && result.result?.data) {
setProviders(result.result.data);
}
} catch (err) {
console.error("Failed to load providers:", err);
} finally {
setLoading(false);
}
};
createEffect(() => {
loadProviders();
});
const handleUnlink = async (provider: string) => {
if (!confirm(`Are you sure you want to unlink your ${provider} account?`)) {
return;
}
setUnlinkLoading(provider);
try {
const response = await fetch("/api/trpc/user.unlinkProvider", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider })
});
const result = await response.json();
if (response.ok && result.result?.data?.success) {
await loadProviders();
alert(`${provider} account unlinked successfully`);
} else {
alert(result.error?.message || "Failed to unlink provider");
}
} catch (err) {
console.error("Failed to unlink provider:", err);
alert("Failed to unlink provider");
} finally {
setUnlinkLoading(null);
}
};
const getProviderIcon = (provider: string) => {
switch (provider) {
case "google":
return <GoogleLogo height={20} width={20} />;
case "github":
return <GitHub height={20} width={20} fill="currentColor" />;
case "email":
return <EmailIcon height={20} width={20} />;
default:
return null;
}
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric"
});
};
return (
<div class="space-y-4">
<Show when={loading()}>
<div class="text-center text-sm">Loading providers...</div>
</Show>
<Show when={!loading() && providers().length === 0}>
<div class="text-center text-sm">No linked providers found</div>
</Show>
<For each={providers()}>
{(provider) => (
<div class="bg-surface1 flex items-center justify-between rounded-lg p-4">
<div class="flex items-center gap-3">
<span class="text-blue">
{getProviderIcon(provider.provider)}
</span>
<div>
<div class="font-semibold capitalize">{provider.provider}</div>
<Show when={provider.email}>
<div class="text-subtext0 text-sm">{provider.email}</div>
</Show>
<Show when={provider.lastUsedAt}>
<div class="text-subtext0 text-xs">
Last used: {formatDate(provider.lastUsedAt)}
</div>
</Show>
</div>
</div>
<Show when={providers().length > 1}>
<button
onClick={() => handleUnlink(provider.provider)}
disabled={unlinkLoading() === provider.provider}
class="text-red hover:text-red rounded px-3 py-1 text-sm transition-all hover:brightness-125 disabled:opacity-50"
>
{unlinkLoading() === provider.provider
? "Unlinking..."
: "Unlink"}
</button>
</Show>
<Show when={providers().length === 1}>
<div class="text-subtext0 text-xs italic">Primary method</div>
</Show>
</div>
)}
</For>
</div>
);
}
function ActiveSessions(props: { userId: string }) {
const [sessions, setSessions] = createSignal<any[]>([]);
const [loading, setLoading] = createSignal(true);
const [revokeLoading, setRevokeLoading] = createSignal<string | null>(null);
const loadSessions = async () => {
try {
const response = await fetch("/api/trpc/user.getSessions");
const result = await response.json();
if (response.ok && result.result?.data) {
setSessions(result.result.data);
}
} catch (err) {
console.error("Failed to load sessions:", err);
} finally {
setLoading(false);
}
};
createEffect(() => {
loadSessions();
});
const handleRevoke = async (sessionId: string, isCurrent: boolean) => {
if (isCurrent) {
if (
!confirm(
"This will sign you out of this device. Are you sure you want to continue?"
)
) {
return;
}
} else {
if (!confirm("Are you sure you want to revoke this session?")) {
return;
}
}
setRevokeLoading(sessionId);
try {
const response = await fetch("/api/trpc/user.revokeSession", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId })
});
const result = await response.json();
if (response.ok && result.result?.data?.success) {
if (isCurrent) {
window.location.href = "/login";
} else {
await loadSessions();
alert("Session revoked successfully");
}
} else {
alert(result.error?.message || "Failed to revoke session");
}
} catch (err) {
console.error("Failed to revoke session:", err);
alert("Failed to revoke session");
} finally {
setRevokeLoading(null);
}
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit"
});
};
const parseUserAgent = (ua: string) => {
const browser =
ua.match(/(Chrome|Firefox|Safari|Edge)\/[\d.]+/)?.[0] ||
"Unknown browser";
const os = ua.match(/(Windows|Mac|Linux|Android|iOS)/)?.[0] || "Unknown OS";
return { browser, os };
};
return (
<div class="space-y-4">
<Show when={loading()}>
<div class="text-center text-sm">Loading sessions...</div>
</Show>
<Show when={!loading() && sessions().length === 0}>
<div class="text-center text-sm">No active sessions found</div>
</Show>
<For each={sessions()}>
{(session) => {
const { browser, os } = parseUserAgent(session.userAgent || "");
return (
<div class="bg-surface1 rounded-lg p-4">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2">
<div class="font-semibold">{browser}</div>
<Show when={session.isCurrent}>
<span class="text-green bg-green/20 rounded px-2 py-0.5 text-xs font-semibold">
Current
</span>
</Show>
</div>
<div class="text-subtext0 mt-1 space-y-1 text-sm">
<div>{os}</div>
<Show when={session.clientIp}>
<div>IP: {session.clientIp}</div>
</Show>
<div>
Last active:{" "}
{formatDate(session.lastRotatedAt || session.createdAt)}
</div>
<Show when={session.expiresAt}>
<div class="text-xs">
Expires: {formatDate(session.expiresAt)}
</div>
</Show>
</div>
</div>
<button
onClick={() =>
handleRevoke(session.sessionId, session.isCurrent)
}
disabled={revokeLoading() === session.sessionId}
class="text-red hover:text-red rounded px-3 py-1 text-sm transition-all hover:brightness-125 disabled:opacity-50"
>
{revokeLoading() === session.sessionId
? "Revoking..."
: "Revoke"}
</button>
</div>
</div>
);
}}
</For>
</div>
);
}

View File

@@ -217,7 +217,11 @@ export default function LoginPage() {
errorMsg.includes("duplicate") ||
errorMsg.includes("already exists")
) {
setError("duplicate");
if (errorMsg.includes("sign in and add a password")) {
setError("provider_exists");
} else {
setError("duplicate");
}
} else {
setError(errorMsg);
}
@@ -423,6 +427,16 @@ export default function LoginPage() {
Email Already Exists!
</div>
</Show>
<Show when={error() === "provider_exists"}>
<div class="mb-2 text-base font-semibold">
Account Already Exists
</div>
<div class="text-crust text-sm">
An account with this email already exists. Please sign in
using your provider (Google/GitHub) and add a password from
your account settings.
</div>
</Show>
<Show
when={
error().includes("Account locked") ||
@@ -445,6 +459,7 @@ export default function LoginPage() {
error() &&
error() !== "passwordMismatch" &&
error() !== "duplicate" &&
error() !== "provider_exists" &&
!error().includes("Account locked") &&
!error().includes("Account is locked") &&
!error().includes("Too many attempts")

View File

@@ -9,6 +9,7 @@ import { blogRouter } from "./routers/blog";
import { gitActivityRouter } from "./routers/git-activity";
import { postHistoryRouter } from "./routers/post-history";
import { infillRouter } from "./routers/infill";
import { accountRouter } from "./routers/account";
import { createTRPCRouter, createTRPCContext } from "./utils";
import type { H3Event } from "h3";
@@ -23,7 +24,8 @@ export const appRouter = createTRPCRouter({
blog: blogRouter,
gitActivity: gitActivityRouter,
postHistory: postHistoryRouter,
infill: infillRouter
infill: infillRouter,
account: accountRouter
});
export type AppRouter = typeof appRouter;

View File

@@ -0,0 +1,255 @@
import { createTRPCRouter, protectedProcedure } from "../utils";
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import {
getUserProviders,
unlinkProvider,
getProviderSummary
} from "~/server/provider-helpers";
import {
getUserActiveSessions,
revokeUserSession,
revokeOtherUserSessions,
getSessionCountByDevice
} from "~/server/session-management";
import { getAuthSession } from "~/server/session-helpers";
import { logAuditEvent } from "~/server/audit";
import { getAuditContext } from "~/server/security";
import type { H3Event } from "vinxi/http";
import type { Context } from "../utils";
/**
* Extract H3Event from Context
*/
function getH3Event(ctx: Context): H3Event {
if (ctx.event && "nativeEvent" in ctx.event && ctx.event.nativeEvent) {
return ctx.event.nativeEvent as H3Event;
}
return ctx.event as unknown as H3Event;
}
export const accountRouter = createTRPCRouter({
/**
* Get all linked authentication providers for current user
*/
getLinkedProviders: protectedProcedure.query(async ({ ctx }) => {
try {
const userId = ctx.userId!;
const summary = await getProviderSummary(userId);
return {
success: true,
providers: summary.providers,
count: summary.count
};
} catch (error) {
console.error("Error fetching linked providers:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to fetch linked providers"
});
}
}),
/**
* Unlink an authentication provider
*/
unlinkProvider: protectedProcedure
.input(
z.object({
provider: z.enum(["email", "google", "github"])
})
)
.mutation(async ({ input, ctx }) => {
try {
const userId = ctx.userId!;
const { provider } = input;
await unlinkProvider(userId, provider);
// Log audit event
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({
userId,
eventType: "auth.provider.unlinked",
eventData: { provider },
ipAddress,
userAgent,
success: true
});
return {
success: true,
message: `${provider} authentication unlinked successfully`
};
} catch (error) {
console.error("Error unlinking provider:", error);
if (error instanceof Error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: error.message
});
}
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to unlink provider"
});
}
}),
/**
* Get all active sessions for current user
*/
getActiveSessions: protectedProcedure.query(async ({ ctx }) => {
try {
const userId = ctx.userId!;
const sessions = await getUserActiveSessions(userId);
// Mark current session
const currentSession = await getAuthSession(getH3Event(ctx));
const currentSessionId = currentSession?.sessionId;
const sessionsWithCurrent = sessions.map((session) => ({
...session,
current: session.sessionId === currentSessionId
}));
return {
success: true,
sessions: sessionsWithCurrent
};
} catch (error) {
console.error("Error fetching active sessions:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to fetch active sessions"
});
}
}),
/**
* Get session statistics by device type
*/
getSessionStats: protectedProcedure.query(async ({ ctx }) => {
try {
const userId = ctx.userId!;
const stats = await getSessionCountByDevice(userId);
return {
success: true,
stats
};
} catch (error) {
console.error("Error fetching session stats:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to fetch session stats"
});
}
}),
/**
* Revoke a specific session
*/
revokeSession: protectedProcedure
.input(
z.object({
sessionId: z.string()
})
)
.mutation(async ({ input, ctx }) => {
try {
const userId = ctx.userId!;
const { sessionId } = input;
await revokeUserSession(userId, sessionId);
// Log audit event
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({
userId,
eventType: "auth.session_revoked",
eventData: { sessionId, reason: "user_request" },
ipAddress,
userAgent,
success: true
});
return {
success: true,
message: "Session revoked successfully"
};
} catch (error) {
console.error("Error revoking session:", error);
if (error instanceof Error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: error.message
});
}
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to revoke session"
});
}
}),
/**
* Revoke all other sessions (keep current session active)
*/
revokeOtherSessions: protectedProcedure.mutation(async ({ ctx }) => {
try {
const userId = ctx.userId!;
// Get current session
const currentSession = await getAuthSession(getH3Event(ctx));
if (!currentSession) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "No active session found"
});
}
const revokedCount = await revokeOtherUserSessions(
userId,
currentSession.sessionId
);
// Log audit event
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({
userId,
eventType: "auth.sessions_bulk_revoked",
eventData: {
revokedCount,
keptSession: currentSession.sessionId,
reason: "user_request"
},
ipAddress,
userAgent,
success: true
});
return {
success: true,
message: `${revokedCount} session(s) revoked successfully`,
revokedCount
};
} catch (error) {
console.error("Error revoking other sessions:", error);
if (error instanceof TRPCError) {
throw error;
}
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to revoke sessions"
});
}
})
});

View File

@@ -10,6 +10,12 @@ import {
} from "~/server/utils";
import { setCookie, getCookie } from "vinxi/http";
import type { User } from "~/db/types";
import {
linkProvider,
findUserByProvider,
findUserByEmail,
updateProviderLastUsed
} from "~/server/provider-helpers";
import {
fetchWithTimeout,
checkResponse,
@@ -259,72 +265,96 @@ export const authRouter = createTRPCRouter({
const conn = ConnectionFactory();
console.log("[GitHub Callback] Checking if user exists...");
const query = `SELECT * FROM User WHERE provider = ? AND display_name = ?`;
const params = ["github", login];
const res = await conn.execute({ sql: query, args: params });
let userId: string;
// Strategy 1: Check if this GitHub identity already linked
let userId = await findUserByProvider("github", login);
if (res.rows[0]) {
userId = (res.rows[0] as unknown as User).id;
console.log("[GitHub Callback] Existing user found:", userId);
let isNewUser = false;
let isLinkedAccount = false;
try {
await conn.execute({
sql: `UPDATE User SET email = ?, email_verified = ?, image = ? WHERE id = ?`,
args: [email, emailVerified ? 1 : 0, icon, userId]
});
console.log("[GitHub Callback] User data updated");
} catch (updateError: any) {
if (
updateError.code === "SQLITE_CONSTRAINT" &&
updateError.message?.includes("User.email")
) {
console.error(
"[GitHub Callback] Email conflict during update:",
email
);
throw new TRPCError({
code: "CONFLICT",
message:
"This email is already associated with another account. Please sign in with that account or use a different email address."
});
}
throw updateError;
}
if (userId) {
console.log(
"[GitHub Callback] Existing GitHub provider found:",
userId
);
// Update provider info
await updateProviderLastUsed(userId, "github");
} else {
userId = uuidV4();
console.log("[GitHub Callback] Creating new user:", userId);
const insertQuery = `INSERT INTO User (id, email, email_verified, display_name, provider, image) VALUES (?, ?, ?, ?, ?, ?)`;
const insertParams = [
userId,
email,
emailVerified ? 1 : 0,
login,
"github",
icon
];
try {
await conn.execute({ sql: insertQuery, args: insertParams });
console.log("[GitHub Callback] New user created");
} catch (insertError: any) {
if (
insertError.code === "SQLITE_CONSTRAINT" &&
insertError.message?.includes("User.email")
) {
console.error(
"[GitHub Callback] Email conflict during insert:",
email
// Strategy 2: Check if email matches existing user (account linking)
if (email) {
userId = await findUserByEmail(email);
if (userId) {
console.log(
"[GitHub Callback] Found existing user by email, linking GitHub account:",
userId
);
throw new TRPCError({
code: "CONFLICT",
message:
"This email is already associated with another account. Please sign in with that account or use a different email address."
});
// Link GitHub to existing account
try {
await linkProvider(userId, "github", {
providerUserId: login,
email: email,
displayName: login,
image: icon
});
isLinkedAccount = true;
} catch (linkError: any) {
console.error(
"[GitHub Callback] Failed to link provider:",
linkError.message
);
throw new TRPCError({
code: "CONFLICT",
message: linkError.message
});
}
}
}
// Strategy 3: Create new user
if (!userId) {
userId = uuidV4();
console.log("[GitHub Callback] Creating new user:", userId);
const insertQuery = `INSERT INTO User (id, email, email_verified, display_name, provider, image) VALUES (?, ?, ?, ?, ?, ?)`;
const insertParams = [
userId,
email,
emailVerified ? 1 : 0,
login,
"github",
icon
];
try {
await conn.execute({ sql: insertQuery, args: insertParams });
// Also create UserProvider entry for new user
await linkProvider(userId, "github", {
providerUserId: login,
email: email,
displayName: login,
image: icon
});
isNewUser = true;
console.log("[GitHub Callback] New user created");
} catch (insertError: any) {
if (
insertError.code === "SQLITE_CONSTRAINT" &&
insertError.message?.includes("User.email")
) {
console.error(
"[GitHub Callback] Email conflict during insert:",
email
);
throw new TRPCError({
code: "CONFLICT",
message:
"This email is already associated with another account. Please sign in with that account or use a different email address."
});
}
throw insertError;
}
throw insertError;
}
}
@@ -352,7 +382,11 @@ export const authRouter = createTRPCRouter({
await logAuditEvent({
userId,
eventType: "auth.login.success",
eventData: { method: "github", isNewUser: !res.rows[0] },
eventData: {
method: "github",
isNewUser,
isLinkedAccount
},
ipAddress: clientIP,
userAgent,
success: true
@@ -485,57 +519,97 @@ export const authRouter = createTRPCRouter({
const conn = ConnectionFactory();
console.log("[Google Callback] Checking if user exists...");
const query = `SELECT * FROM User WHERE provider = ? AND email = ?`;
const params = ["google", email];
const res = await conn.execute({ sql: query, args: params });
let userId: string;
// Strategy 1: Check if this Google identity already linked
let userId = await findUserByProvider("google", email);
if (res.rows[0]) {
userId = (res.rows[0] as unknown as User).id;
console.log("[Google Callback] Existing user found:", userId);
let isNewUser = false;
let isLinkedAccount = false;
await conn.execute({
sql: `UPDATE User SET email = ?, email_verified = ?, display_name = ?, image = ? WHERE id = ?`,
args: [email, email_verified ? 1 : 0, name, image, userId]
});
console.log("[Google Callback] User data updated");
if (userId) {
console.log(
"[Google Callback] Existing Google provider found:",
userId
);
// Update provider info
await updateProviderLastUsed(userId, "google");
} else {
userId = uuidV4();
console.log("[Google Callback] Creating new user:", userId);
const insertQuery = `INSERT INTO User (id, email, email_verified, display_name, provider, image) VALUES (?, ?, ?, ?, ?, ?)`;
const insertParams = [
userId,
email,
email_verified ? 1 : 0,
name,
"google",
image
];
try {
await conn.execute({
sql: insertQuery,
args: insertParams
});
console.log("[Google Callback] New user created");
} catch (insertError: any) {
if (
insertError.code === "SQLITE_CONSTRAINT" &&
insertError.message?.includes("User.email")
) {
// Strategy 2: Check if email matches existing user (account linking)
userId = await findUserByEmail(email);
if (userId) {
console.log(
"[Google Callback] Found existing user by email, linking Google account:",
userId
);
// Link Google to existing account
try {
await linkProvider(userId, "google", {
providerUserId: email,
email: email,
displayName: name,
image: image
});
isLinkedAccount = true;
} catch (linkError: any) {
console.error(
"[Google Callback] Email conflict during insert:",
email
"[Google Callback] Failed to link provider:",
linkError.message
);
throw new TRPCError({
code: "CONFLICT",
message:
"This email is already associated with another account. Please sign in with that account instead."
message: linkError.message
});
}
throw insertError;
}
// Strategy 3: Create new user
if (!userId) {
userId = uuidV4();
console.log("[Google Callback] Creating new user:", userId);
const insertQuery = `INSERT INTO User (id, email, email_verified, display_name, provider, image) VALUES (?, ?, ?, ?, ?, ?)`;
const insertParams = [
userId,
email,
email_verified ? 1 : 0,
name,
"google",
image
];
try {
await conn.execute({
sql: insertQuery,
args: insertParams
});
// Also create UserProvider entry for new user
await linkProvider(userId, "google", {
providerUserId: email,
email: email,
displayName: name,
image: image
});
isNewUser = true;
console.log("[Google Callback] New user created");
} catch (insertError: any) {
if (
insertError.code === "SQLITE_CONSTRAINT" &&
insertError.message?.includes("User.email")
) {
console.error(
"[Google Callback] Email conflict during insert:",
email
);
throw new TRPCError({
code: "CONFLICT",
message:
"This email is already associated with another account. Please sign in with that account instead."
});
}
throw insertError;
}
}
}
@@ -563,7 +637,11 @@ export const authRouter = createTRPCRouter({
await logAuditEvent({
userId,
eventType: "auth.login.success",
eventData: { method: "google", isNewUser: !res.rows[0] },
eventData: {
method: "google",
isNewUser,
isLinkedAccount
},
ipAddress: clientIP,
userAgent,
success: true
@@ -989,6 +1067,36 @@ export const authRouter = createTRPCRouter({
});
}
// Check if email already exists (User table or UserProvider table)
const existingUserId = await findUserByEmail(email);
if (existingUserId) {
// User exists - check if they have a password
const conn = ConnectionFactory();
const userCheck = await conn.execute({
sql: "SELECT password_hash, provider FROM User WHERE id = ?",
args: [existingUserId]
});
if (userCheck.rows.length > 0) {
const existingUser = userCheck.rows[0] as any;
// If user has a password, it's a duplicate registration attempt
if (existingUser.password_hash) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "duplicate"
});
}
// If user doesn't have a password (provider-only), redirect to login
throw new TRPCError({
code: "BAD_REQUEST",
message:
"An account with this email already exists. Please sign in and add a password from your account settings."
});
}
}
const passwordHash = await hashPassword(password);
const conn = ConnectionFactory();
const userId = uuidV4();
@@ -999,6 +1107,12 @@ export const authRouter = createTRPCRouter({
args: [userId, email, passwordHash, "email"]
});
// Create UserProvider entry for email auth
await linkProvider(userId, "email", {
providerUserId: email,
email: email
});
// Create session with client info
const clientIP = getClientIP(getH3Event(ctx));
const userAgent = getUserAgent(getH3Event(ctx));

View File

@@ -4,14 +4,11 @@ import { ConnectionFactory, hashPassword, checkPassword } from "~/server/utils";
import { setCookie } from "vinxi/http";
import type { User } from "~/db/types";
import { toUserProfile } from "~/types/user";
import {
updateEmailSchema,
updateDisplayNameSchema,
updateProfileImageSchema,
changePasswordSchema,
setPasswordSchema,
deleteAccountSchema
} from "../schemas/user";
import { getUserProviders, unlinkProvider } from "~/server/provider-helpers";
import { z } from "zod";
import { getAuthSession } from "~/server/session-helpers";
import { logAuditEvent } from "~/server/audit";
import { getClientIP, getUserAgent } from "~/server/security";
export const userRouter = createTRPCRouter({
getProfile: publicProcedure.query(async ({ ctx }) => {
@@ -242,6 +239,55 @@ export const userRouter = createTRPCRouter({
args: [passwordHash, userId]
});
// Send email notification about password being set
if (user.email) {
try {
const { generatePasswordSetEmail } =
await import("~/server/email-templates");
const { formatDeviceDescription } =
await import("~/server/device-utils");
const { default: sendEmail } = await import("~/server/email");
const h3Event = ctx.event.nativeEvent
? ctx.event.nativeEvent
: (ctx.event as any);
const clientIP = getClientIP(h3Event);
const userAgent = getUserAgent(h3Event);
const deviceInfo = formatDeviceDescription({
userAgent
});
const providerName =
user.provider === "google"
? "Google"
: user.provider === "github"
? "GitHub"
: "provider";
const htmlContent = generatePasswordSetEmail({
providerName,
setTime: new Date().toLocaleString(),
deviceInfo,
ipAddress: clientIP
});
await sendEmail(
user.email,
"Password Added to Your Account",
htmlContent
);
console.log(`[setPassword] Confirmation email sent to ${user.email}`);
} catch (emailError) {
console.error(
"[setPassword] Failed to send confirmation email:",
emailError
);
// Don't fail the operation if email fails
}
}
return { success: true, message: "success" };
}),
@@ -303,5 +349,152 @@ export const userRouter = createTRPCRouter({
});
return { success: true, message: "deleted" };
}),
getProviders: publicProcedure.query(async ({ ctx }) => {
const userId = ctx.userId;
if (!userId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Not authenticated"
});
}
const providers = await getUserProviders(userId);
return providers.map((p) => ({
id: p.id,
provider: p.provider,
email: p.email || undefined,
displayName: p.display_name || undefined,
lastUsedAt: p.last_used_at,
createdAt: p.created_at
}));
}),
unlinkProvider: publicProcedure
.input(
z.object({
provider: z.enum(["email", "google", "github"])
})
)
.mutation(async ({ input, ctx }) => {
const userId = ctx.userId;
if (!userId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Not authenticated"
});
}
await unlinkProvider(userId, input.provider);
return { success: true, message: "Provider unlinked" };
}),
getSessions: publicProcedure.query(async ({ ctx }) => {
const userId = ctx.userId;
if (!userId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Not authenticated"
});
}
const conn = ConnectionFactory();
const res = await conn.execute({
sql: `SELECT session_id, token_family, created_at, expires_at, last_rotated_at,
rotation_count, client_ip, user_agent
FROM Session
WHERE user_id = ? AND revoked = 0 AND expires_at > datetime('now')
ORDER BY last_rotated_at DESC`,
args: [userId]
});
// Get current session to mark it
const currentSession = await getAuthSession(ctx.event as any);
return res.rows.map((row: any) => ({
sessionId: row.session_id,
tokenFamily: row.token_family,
createdAt: row.created_at,
expiresAt: row.expires_at,
lastRotatedAt: row.last_rotated_at,
rotationCount: row.rotation_count,
clientIp: row.client_ip,
userAgent: row.user_agent,
isCurrent: currentSession?.sessionId === row.session_id
}));
}),
revokeSession: publicProcedure
.input(
z.object({
sessionId: z.string()
})
)
.mutation(async ({ input, ctx }) => {
const userId = ctx.userId;
if (!userId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Not authenticated"
});
}
const conn = ConnectionFactory();
// Verify session belongs to this user
const sessionCheck = await conn.execute({
sql: "SELECT user_id, token_family FROM Session WHERE session_id = ?",
args: [input.sessionId]
});
if (sessionCheck.rows.length === 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Session not found"
});
}
const session = sessionCheck.rows[0] as any;
if (session.user_id !== userId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Cannot revoke another user's session"
});
}
// Revoke the entire token family (all sessions on this device)
await conn.execute({
sql: "UPDATE Session SET revoked = 1 WHERE token_family = ?",
args: [session.token_family]
});
// Log audit event
const h3Event = ctx.event.nativeEvent
? ctx.event.nativeEvent
: (ctx.event as any);
const clientIP = getClientIP(h3Event);
const userAgent = getUserAgent(h3Event);
await logAuditEvent({
userId,
eventType: "auth.session_revoked",
eventData: {
sessionId: input.sessionId,
tokenFamily: session.token_family,
reason: "user_revoked"
},
ipAddress: clientIP,
userAgent,
success: true
});
return { success: true, message: "Session revoked" };
})
});

102
src/server/device-utils.ts Normal file
View File

@@ -0,0 +1,102 @@
import type { H3Event } from "vinxi/http";
import UAParser from "ua-parser-js";
export interface DeviceInfo {
deviceName?: string;
deviceType?: "desktop" | "mobile" | "tablet";
browser?: string;
os?: string;
}
/**
* Parse user agent string to extract device information
* @param userAgent - User agent string from request headers
* @returns Parsed device information
*/
export function parseDeviceInfo(userAgent: string): DeviceInfo {
const parser = new UAParser(userAgent);
const result = parser.getResult();
// Determine device type
let deviceType: "desktop" | "mobile" | "tablet" = "desktop";
if (result.device.type === "mobile") {
deviceType = "mobile";
} else if (result.device.type === "tablet") {
deviceType = "tablet";
}
// Build device name (e.g., "iPhone 14", "Windows PC", "iPad Pro")
let deviceName: string | undefined;
if (result.device.vendor && result.device.model) {
deviceName = `${result.device.vendor} ${result.device.model}`;
} else if (result.os.name) {
deviceName = `${result.os.name} ${deviceType === "desktop" ? "Computer" : deviceType}`;
}
// Browser info (e.g., "Chrome 120")
const browser =
result.browser.name && result.browser.version
? `${result.browser.name} ${result.browser.version.split(".")[0]}`
: result.browser.name;
// OS info (e.g., "macOS 14.1", "Windows 11", "iOS 17")
const os =
result.os.name && result.os.version
? `${result.os.name} ${result.os.version}`
: result.os.name;
return {
deviceName,
deviceType,
browser,
os
};
}
/**
* Extract device information from H3Event
* @param event - H3Event
* @returns Device information
*/
export function getDeviceInfo(event: H3Event): DeviceInfo {
const userAgent = event.node.req.headers["user-agent"] || "";
return parseDeviceInfo(userAgent);
}
/**
* Generate a human-readable device description
* @param deviceInfo - Device information
* @returns Formatted device string (e.g., "Chrome on macOS", "iPhone")
*/
export function formatDeviceDescription(deviceInfo: DeviceInfo): string {
const parts: string[] = [];
if (deviceInfo.deviceName) {
parts.push(deviceInfo.deviceName);
}
if (deviceInfo.browser) {
parts.push(deviceInfo.browser);
}
if (deviceInfo.os && !deviceInfo.deviceName?.includes(deviceInfo.os)) {
parts.push(`on ${deviceInfo.os}`);
}
return parts.length > 0 ? parts.join(" • ") : "Unknown Device";
}
/**
* Create a short device fingerprint for comparison
* Not cryptographic, just for grouping similar sessions
* @param deviceInfo - Device information
* @returns Short fingerprint string
*/
export function createDeviceFingerprint(deviceInfo: DeviceInfo): string {
const parts = [
deviceInfo.deviceType || "unknown",
deviceInfo.os?.split(" ")[0] || "unknown",
deviceInfo.browser?.split(" ")[0] || "unknown"
];
return parts.join("-").toLowerCase();
}

View File

@@ -4,6 +4,9 @@ import { AUTH_CONFIG } from "~/config";
import loginLinkTemplate from "./login-link.html?raw";
import passwordResetTemplate from "./password-reset.html?raw";
import emailVerificationTemplate from "./email-verification.html?raw";
import providerLinkedTemplate from "./provider-linked.html?raw";
import newDeviceLoginTemplate from "./new-device-login.html?raw";
import passwordSetTemplate from "./password-set.html?raw";
/**
* Convert expiry string to human-readable format
@@ -94,3 +97,68 @@ export function generateEmailVerificationEmail(
EXPIRY_TIME: expiryTime
});
}
export interface ProviderLinkedEmailParams {
providerName: string;
providerEmail?: string;
linkTime: string;
deviceInfo: string;
}
/**
* Generate provider linked notification email HTML
*/
export function generateProviderLinkedEmail(
params: ProviderLinkedEmailParams
): string {
return processTemplate(providerLinkedTemplate, {
PROVIDER_NAME: params.providerName,
PROVIDER_EMAIL: params.providerEmail || "N/A",
LINK_TIME: params.linkTime,
DEVICE_INFO: params.deviceInfo
});
}
export interface NewDeviceLoginEmailParams {
deviceInfo: string;
loginTime: string;
ipAddress: string;
loginMethod: string;
accountUrl: string;
}
/**
* Generate new device login notification email HTML
*/
export function generateNewDeviceLoginEmail(
params: NewDeviceLoginEmailParams
): string {
return processTemplate(newDeviceLoginTemplate, {
DEVICE_INFO: params.deviceInfo,
LOGIN_TIME: params.loginTime,
IP_ADDRESS: params.ipAddress,
LOGIN_METHOD: params.loginMethod,
ACCOUNT_URL: params.accountUrl
});
}
export interface PasswordSetEmailParams {
providerName: string;
setTime: string;
deviceInfo: string;
ipAddress: string;
}
/**
* Generate password set notification email HTML
*/
export function generatePasswordSetEmail(
params: PasswordSetEmailParams
): string {
return processTemplate(passwordSetTemplate, {
PROVIDER_NAME: params.providerName,
SET_TIME: params.setTime,
DEVICE_INFO: params.deviceInfo,
IP_ADDRESS: params.ipAddress
});
}

View File

@@ -0,0 +1,131 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>New Device Login</title>
</head>
<body
style="
font-family:
-apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, Roboto,
&quot;Helvetica Neue&quot;, Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
"
>
<div
style="
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 30px;
border-radius: 10px 10px 0 0;
text-align: center;
"
>
<h1 style="color: white; margin: 0; font-size: 24px">
New Device Login Detected
</h1>
</div>
<div
style="
background: #ffffff;
padding: 30px;
border: 1px solid #e0e0e0;
border-top: none;
border-radius: 0 0 10px 10px;
"
>
<p style="font-size: 16px; margin-top: 0">Hello,</p>
<p style="font-size: 16px">
We detected a new login to your account from a device we haven't seen
before:
</p>
<div
style="
background: #f8f9fa;
border-left: 4px solid #667eea;
padding: 15px;
margin: 20px 0;
"
>
<p style="margin: 0; font-size: 16px">
<strong>Device:</strong> {{DEVICE_INFO}}
</p>
<p style="margin: 10px 0 0 0; font-size: 14px; color: #666">
<strong>Time:</strong> {{LOGIN_TIME}}
</p>
<p style="margin: 10px 0 0 0; font-size: 14px; color: #666">
<strong>Location:</strong> {{IP_ADDRESS}}
</p>
<p style="margin: 10px 0 0 0; font-size: 14px; color: #666">
<strong>Method:</strong> {{LOGIN_METHOD}}
</p>
</div>
<p style="font-size: 16px">
If this was you, you can safely ignore this email.
</p>
<div
style="
background: #fff3cd;
border: 1px solid #ffc107;
border-radius: 5px;
padding: 15px;
margin: 20px 0;
"
>
<p style="margin: 0; font-size: 14px; color: #856404">
<strong>⚠️ Wasn't you?</strong><br />
If you didn't log in from this device, your account may be
compromised. Please sign in immediately and:
</p>
<ul
style="
margin: 10px 0 0 0;
padding-left: 20px;
font-size: 14px;
color: #856404;
"
>
<li>Revoke all active sessions</li>
<li>Change your password</li>
<li>Review linked authentication providers</li>
</ul>
</div>
<div style="text-align: center; margin: 30px 0">
<a
href="{{ACCOUNT_URL}}"
style="
display: inline-block;
background: #667eea;
color: white;
padding: 12px 30px;
text-decoration: none;
border-radius: 5px;
font-weight: bold;
"
>
Review Account Security
</a>
</div>
<p style="font-size: 14px; color: #666; margin-bottom: 0">Best regards</p>
</div>
<div
style="text-align: center; padding: 20px; font-size: 12px; color: #999"
>
<p style="margin: 0">
This is an automated security notification from freno.me
</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,103 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Password Added to Account</title>
</head>
<body
style="
font-family:
-apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, Roboto,
&quot;Helvetica Neue&quot;, Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
"
>
<div
style="
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 30px;
border-radius: 10px 10px 0 0;
text-align: center;
"
>
<h1 style="color: white; margin: 0; font-size: 24px">
Password Added to Your Account
</h1>
</div>
<div
style="
background: #ffffff;
padding: 30px;
border: 1px solid #e0e0e0;
border-top: none;
border-radius: 0 0 10px 10px;
"
>
<p style="font-size: 16px; margin-top: 0">Hello,</p>
<p style="font-size: 16px">
A password has been successfully added to your account. You can now sign
in using your email and password in addition to your existing
authentication methods.
</p>
<div
style="
background: #f8f9fa;
border-left: 4px solid #667eea;
padding: 15px;
margin: 20px 0;
"
>
<p style="margin: 0; font-size: 16px">
<strong>Time:</strong> {{SET_TIME}}
</p>
<p style="margin: 10px 0 0 0; font-size: 14px; color: #666">
<strong>Device:</strong> {{DEVICE_INFO}}
</p>
<p style="margin: 10px 0 0 0; font-size: 14px; color: #666">
<strong>IP Address:</strong> {{IP_ADDRESS}}
</p>
</div>
<p style="font-size: 16px">
This provides you with an additional way to access your account and
ensures you can still sign in even if you lose access to your
{{PROVIDER_NAME}} account.
</p>
<div
style="
background: #fff3cd;
border: 1px solid #ffc107;
border-radius: 5px;
padding: 15px;
margin: 20px 0;
"
>
<p style="margin: 0; font-size: 14px; color: #856404">
<strong>⚠️ Didn't set this password?</strong><br />
If you didn't perform this action, your account security may be at
risk. Please sign in immediately, change your password, and review
your account settings.
</p>
</div>
<p style="font-size: 14px; color: #666; margin-bottom: 0">Best regards</p>
</div>
<div
style="text-align: center; padding: 20px; font-size: 12px; color: #999"
>
<p style="margin: 0">
This is an automated security notification from freno.me
</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,102 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>New Provider Linked</title>
</head>
<body
style="
font-family:
-apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, Roboto,
&quot;Helvetica Neue&quot;, Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
"
>
<div
style="
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 30px;
border-radius: 10px 10px 0 0;
text-align: center;
"
>
<h1 style="color: white; margin: 0; font-size: 24px">
New Login Method Linked
</h1>
</div>
<div
style="
background: #ffffff;
padding: 30px;
border: 1px solid #e0e0e0;
border-top: none;
border-radius: 0 0 10px 10px;
"
>
<p style="font-size: 16px; margin-top: 0">Hello,</p>
<p style="font-size: 16px">
A new authentication provider has been linked to your account:
</p>
<div
style="
background: #f8f9fa;
border-left: 4px solid #667eea;
padding: 15px;
margin: 20px 0;
"
>
<p style="margin: 0; font-size: 16px">
<strong>Provider:</strong> {{PROVIDER_NAME}}
</p>
<p style="margin: 10px 0 0 0; font-size: 14px; color: #666">
<strong>Email:</strong> {{PROVIDER_EMAIL}}
</p>
<p style="margin: 10px 0 0 0; font-size: 14px; color: #666">
<strong>Time:</strong> {{LINK_TIME}}
</p>
<p style="margin: 10px 0 0 0; font-size: 14px; color: #666">
<strong>Device:</strong> {{DEVICE_INFO}}
</p>
</div>
<p style="font-size: 16px">
You can now sign in to your account using {{PROVIDER_NAME}}.
</p>
<div
style="
background: #fff3cd;
border: 1px solid #ffc107;
border-radius: 5px;
padding: 15px;
margin: 20px 0;
"
>
<p style="margin: 0; font-size: 14px; color: #856404">
<strong>⚠️ Didn't link this provider?</strong><br />
If you didn't perform this action, your account security may be at
risk. Please sign in and remove this provider immediately, then change
your password.
</p>
</div>
<p style="font-size: 14px; color: #666; margin-bottom: 0">Best regards</p>
</div>
<div
style="text-align: center; padding: 20px; font-size: 12px; color: #999"
>
<p style="margin: 0">
This is an automated security notification from freno.me
</p>
</div>
</body>
</html>

View File

@@ -1,9 +1,76 @@
import { SignJWT } from "jose";
import { env } from "~/env/server";
import { AUTH_CONFIG } from "~/config";
import { AUTH_CONFIG, NETWORK_CONFIG } from "~/config";
import {
fetchWithTimeout,
checkResponse,
fetchWithRetry
} from "~/server/fetch-utils";
export const LINEAGE_JWT_EXPIRY = AUTH_CONFIG.LINEAGE_JWT_EXPIRY;
/**
* Generic email sending function
* @param to - Recipient email address
* @param subject - Email subject
* @param htmlContent - HTML content of the email
* @returns Success status
*/
export default async function sendEmail(
to: string,
subject: string,
htmlContent: string
): Promise<{ success: boolean; messageId?: string; message?: string }> {
const apiKey = env.SENDINBLUE_KEY;
const apiUrl = "https://api.sendinblue.com/v3/smtp/email";
const emailPayload = {
sender: {
name: "freno.me",
email: "no_reply@freno.me"
},
to: [{ email: to }],
htmlContent,
subject
};
try {
const response = await fetchWithRetry(
async () => {
const res = await fetchWithTimeout(apiUrl, {
method: "POST",
headers: {
accept: "application/json",
"api-key": apiKey,
"content-type": "application/json"
},
body: JSON.stringify(emailPayload),
timeout: NETWORK_CONFIG.EMAIL_API_TIMEOUT_MS
});
await checkResponse(res);
return res;
},
{
maxRetries: NETWORK_CONFIG.MAX_RETRIES,
retryDelay: NETWORK_CONFIG.RETRY_DELAY_MS
}
);
const json = (await response.json()) as { messageId?: string };
if (json.messageId) {
return { success: true, messageId: json.messageId };
}
return { success: false, message: "No messageId in response" };
} catch (error) {
console.error("Email sending error:", error);
return {
success: false,
message: error instanceof Error ? error.message : "Email service error"
};
}
}
export async function sendEmailVerification(userEmail: string): Promise<{
success: boolean;
messageId?: string;

View File

@@ -0,0 +1,244 @@
import { ConnectionFactory } from "./database";
import { v4 as uuidV4 } from "uuid";
/**
* Migration script to add multi-provider and enhanced session support
* Run this script once to migrate existing database
*/
export async function migrateMultiAuth() {
const conn = ConnectionFactory();
console.log("[Migration] Starting multi-auth migration...");
try {
// Step 1: Check if UserProvider table exists
const tableCheck = await conn.execute({
sql: "SELECT name FROM sqlite_master WHERE type='table' AND name='UserProvider'"
});
if (tableCheck.rows.length > 0) {
console.log(
"[Migration] UserProvider table already exists, skipping creation"
);
} else {
console.log("[Migration] Creating UserProvider table...");
await conn.execute(`
CREATE TABLE UserProvider (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
provider TEXT NOT NULL CHECK(provider IN ('email', 'google', 'github', 'apple')),
provider_user_id TEXT,
email TEXT,
display_name TEXT,
image TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_used_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE
)
`);
console.log("[Migration] Creating UserProvider indexes...");
await conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_user_provider_provider_user ON UserProvider (provider, provider_user_id)"
);
await conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_user_provider_provider_email ON UserProvider (provider, email)"
);
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_user_provider_user_id ON UserProvider (user_id)"
);
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_user_provider_provider ON UserProvider (provider)"
);
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_user_provider_email ON UserProvider (email)"
);
}
// Step 2: Check if Session table has device columns
const sessionColumnsCheck = await conn.execute({
sql: "PRAGMA table_info(Session)"
});
const hasDeviceName = sessionColumnsCheck.rows.some(
(row: any) => row.name === "device_name"
);
if (hasDeviceName) {
console.log(
"[Migration] Session table already has device columns, skipping"
);
} else {
console.log("[Migration] Adding device columns to Session table...");
await conn.execute("ALTER TABLE Session ADD COLUMN device_name TEXT");
await conn.execute("ALTER TABLE Session ADD COLUMN device_type TEXT");
await conn.execute("ALTER TABLE Session ADD COLUMN browser TEXT");
await conn.execute("ALTER TABLE Session ADD COLUMN os TEXT");
// SQLite doesn't support non-constant defaults in ALTER TABLE
// Add column with NULL default, then update existing rows
await conn.execute("ALTER TABLE Session ADD COLUMN last_active_at TEXT");
// Update existing rows to set last_active_at = last_used
console.log(
"[Migration] Updating existing sessions with last_active_at..."
);
await conn.execute(
"UPDATE Session SET last_active_at = COALESCE(last_used, created_at) WHERE last_active_at IS NULL"
);
console.log("[Migration] Creating Session indexes...");
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_session_last_active ON Session (last_active_at)"
);
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_session_user_active ON Session (user_id, revoked, last_active_at)"
);
}
// Step 3: Migrate existing users to UserProvider table
console.log("[Migration] Checking for users to migrate...");
const usersResult = await conn.execute({
sql: "SELECT id, email, provider, display_name, image, apple_user_string FROM User WHERE provider IS NOT NULL"
});
console.log(
`[Migration] Found ${usersResult.rows.length} users to migrate`
);
let migratedCount = 0;
for (const row of usersResult.rows) {
const user = row as any;
// Skip apple provider users (they're for Life and Lineage mobile app, not website auth)
if (user.provider === "apple") {
console.log(
`[Migration] Skipping user ${user.id} with apple provider (mobile app only)`
);
continue;
}
// Check if already migrated
const existingProvider = await conn.execute({
sql: "SELECT id FROM UserProvider WHERE user_id = ? AND provider = ?",
args: [user.id, user.provider || "email"]
});
if (existingProvider.rows.length > 0) {
console.log(
`[Migration] User ${user.id} already migrated, skipping`
);
continue;
}
// Determine provider_user_id based on provider type
let providerUserId: string | null = null;
if (user.provider === "github") {
providerUserId = user.display_name;
} else if (user.provider === "google") {
providerUserId = user.email;
} else {
providerUserId = user.email;
}
try {
await conn.execute({
sql: `INSERT INTO UserProvider (id, user_id, provider, provider_user_id, email, display_name, image)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
args: [
uuidV4(),
user.id,
user.provider || "email",
providerUserId,
user.email,
user.display_name,
user.image
]
});
migratedCount++;
} catch (error: any) {
console.error(
`[Migration] Failed to migrate user ${user.id}:`,
error.message
);
}
}
// Determine provider_user_id based on provider type
let providerUserId: string | null = null;
if (user.provider === "github") {
providerUserId = user.display_name;
} else if (user.provider === "google") {
providerUserId = user.email;
} else if (user.provider === "apple") {
providerUserId = user.apple_user_string;
} else {
providerUserId = user.email;
}
try {
await conn.execute({
sql: `INSERT INTO UserProvider (id, user_id, provider, provider_user_id, email, display_name, image)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
args: [
uuidV4(),
user.id,
user.provider || "email",
providerUserId,
user.email,
user.display_name,
user.image
]
});
migratedCount++;
} catch (error: any) {
console.error(
`[Migration] Failed to migrate user ${user.id}:`,
error.message
);
}
}
console.log(`[Migration] Migrated ${migratedCount} users successfully`);
// Step 4: Verification
console.log("[Migration] Running verification queries...");
const providerCount = await conn.execute({
sql: "SELECT COUNT(*) as count FROM UserProvider"
});
console.log(
`[Migration] Total providers in UserProvider table: ${(providerCount.rows[0] as any).count}`
);
const multiProviderUsers = await conn.execute({
sql: `SELECT COUNT(*) as count FROM (
SELECT user_id FROM UserProvider GROUP BY user_id HAVING COUNT(*) > 1
)`
});
console.log(
`[Migration] Users with multiple providers: ${(multiProviderUsers.rows[0] as any).count}`
);
console.log("[Migration] Multi-auth migration completed successfully!");
return {
success: true,
migratedUsers: migratedCount,
totalProviders: (providerCount.rows[0] as any).count
};
} catch (error) {
console.error("[Migration] Migration failed:", error);
throw error;
}
}
// Run migration if called directly
if (require.main === module) {
migrateMultiAuth()
.then((result) => {
console.log("[Migration] Result:", result);
process.exit(0);
})
.catch((error) => {
console.error("[Migration] Error:", error);
process.exit(1);
});
}

View File

@@ -0,0 +1,350 @@
import { ConnectionFactory } from "./database";
import { v4 as uuidV4 } from "uuid";
import type { UserProvider } from "~/db/types";
import { logAuditEvent } from "./audit";
import { generateProviderLinkedEmail } from "./email-templates";
import { formatDeviceDescription } from "./device-utils";
/**
* Link a new authentication provider to an existing user account
* @param userId - User ID to link provider to
* @param provider - Provider type
* @param providerData - Provider-specific data
* @param options - Optional parameters (deviceInfo, sendEmail)
* @returns Created UserProvider record
*/
export async function linkProvider(
userId: string,
provider: "email" | "google" | "github",
providerData: {
providerUserId?: string;
email?: string;
displayName?: string;
image?: string;
},
options?: {
deviceInfo?: {
deviceName?: string;
deviceType?: string;
browser?: string;
os?: string;
};
sendEmail?: boolean;
}
): Promise<UserProvider> {
const conn = ConnectionFactory();
// Check if provider already linked to this user
const existing = await conn.execute({
sql: "SELECT * FROM UserProvider WHERE user_id = ? AND provider = ?",
args: [userId, provider]
});
if (existing.rows.length > 0) {
throw new Error(`Provider ${provider} already linked to this account`);
}
// Check if provider identity is already used by another user
if (providerData.providerUserId) {
const conflictCheck = await conn.execute({
sql: "SELECT user_id FROM UserProvider WHERE provider = ? AND provider_user_id = ?",
args: [provider, providerData.providerUserId]
});
if (conflictCheck.rows.length > 0) {
const conflictUserId = (conflictCheck.rows[0] as any).user_id;
if (conflictUserId !== userId) {
throw new Error(
`This ${provider} account is already linked to a different user`
);
}
}
}
// Create new provider link
const id = uuidV4();
await conn.execute({
sql: `INSERT INTO UserProvider (id, user_id, provider, provider_user_id, email, display_name, image)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
args: [
id,
userId,
provider,
providerData.providerUserId || null,
providerData.email || null,
providerData.displayName || null,
providerData.image || null
]
});
// Fetch created record
const result = await conn.execute({
sql: "SELECT * FROM UserProvider WHERE id = ?",
args: [id]
});
const userProvider = result.rows[0] as unknown as UserProvider;
// Log audit event
await logAuditEvent({
userId,
eventType: "auth.provider.linked",
eventData: {
provider,
providerEmail: providerData.email
},
success: true
});
// Send notification email if requested and user has email
if (options?.sendEmail !== false) {
try {
// Get user email
const userResult = await conn.execute({
sql: "SELECT email FROM User WHERE id = ?",
args: [userId]
});
const userEmail = userResult.rows[0]
? ((userResult.rows[0] as any).email as string)
: null;
if (userEmail) {
const deviceDescription = options?.deviceInfo
? formatDeviceDescription(options.deviceInfo)
: "Unknown Device";
const htmlContent = generateProviderLinkedEmail({
providerName: provider.charAt(0).toUpperCase() + provider.slice(1),
providerEmail: providerData.email,
linkTime: new Date().toLocaleString(),
deviceInfo: deviceDescription
});
// Import sendEmail dynamically to avoid circular dependency
const { default: sendEmail } = await import("./email");
await sendEmail(
userEmail,
"New Authentication Provider Linked",
htmlContent
);
}
} catch (emailError) {
// Don't fail the operation if email fails
console.error("Failed to send provider linked email:", emailError);
}
}
return userProvider;
}
/**
* Unlink an authentication provider from a user account
* @param userId - User ID
* @param provider - Provider to unlink
* @throws Error if trying to remove last provider
*/
export async function unlinkProvider(
userId: string,
provider: "email" | "google" | "github"
): Promise<void> {
const conn = ConnectionFactory();
// Check how many providers this user has
const providersResult = await conn.execute({
sql: "SELECT COUNT(*) as count FROM UserProvider WHERE user_id = ?",
args: [userId]
});
const providerCount = (providersResult.rows[0] as any).count;
if (providerCount <= 1) {
throw new Error(
"Cannot remove last authentication method. Add another provider first."
);
}
// Delete provider
const result = await conn.execute({
sql: "DELETE FROM UserProvider WHERE user_id = ? AND provider = ?",
args: [userId, provider]
});
if ((result as any).rowsAffected === 0) {
throw new Error(`Provider ${provider} not found for this user`);
}
// Log audit event
await logAuditEvent({
userId,
eventType: "auth.provider.unlinked",
eventData: {
provider
},
success: true
});
}
/**
* Get all authentication providers for a user
* @param userId - User ID
* @returns Array of UserProvider records
*/
export async function getUserProviders(
userId: string
): Promise<UserProvider[]> {
const conn = ConnectionFactory();
const result = await conn.execute({
sql: "SELECT * FROM UserProvider WHERE user_id = ? ORDER BY created_at ASC",
args: [userId]
});
return result.rows as unknown as UserProvider[];
}
/**
* Find user by provider and provider-specific identifier
* @param provider - Provider type
* @param providerUserId - Provider-specific user ID
* @returns User ID if found, null otherwise
*/
export async function findUserByProvider(
provider: "email" | "google" | "github",
providerUserId: string
): Promise<string | null> {
const conn = ConnectionFactory();
const result = await conn.execute({
sql: "SELECT user_id FROM UserProvider WHERE provider = ? AND provider_user_id = ?",
args: [provider, providerUserId]
});
if (result.rows.length === 0) {
return null;
}
return (result.rows[0] as any).user_id;
}
/**
* Find user by provider and email
* Used for account linking when email matches
* @param provider - Provider type
* @param email - Email address
* @returns User ID if found, null otherwise
*/
export async function findUserByProviderEmail(
provider: "email" | "google" | "github",
email: string
): Promise<string | null> {
const conn = ConnectionFactory();
const result = await conn.execute({
sql: "SELECT user_id FROM UserProvider WHERE provider = ? AND email = ?",
args: [provider, email]
});
if (result.rows.length === 0) {
return null;
}
return (result.rows[0] as any).user_id;
}
/**
* Find any user by email across all providers
* Used for cross-provider account linking
* @param email - Email address
* @returns User ID if found, null otherwise
*/
export async function findUserByEmail(email: string): Promise<string | null> {
const conn = ConnectionFactory();
// First check User table
const userResult = await conn.execute({
sql: "SELECT id FROM User WHERE email = ?",
args: [email]
});
if (userResult.rows.length > 0) {
return (userResult.rows[0] as any).id;
}
// Then check UserProvider table
const providerResult = await conn.execute({
sql: "SELECT user_id FROM UserProvider WHERE email = ? LIMIT 1",
args: [email]
});
if (providerResult.rows.length > 0) {
return (providerResult.rows[0] as any).user_id;
}
return null;
}
/**
* Update last_used_at timestamp for a provider
* Call this on successful login with that provider
* @param userId - User ID
* @param provider - Provider that was used
*/
export async function updateProviderLastUsed(
userId: string,
provider: "email" | "google" | "github"
): Promise<void> {
const conn = ConnectionFactory();
await conn.execute({
sql: "UPDATE UserProvider SET last_used_at = datetime('now') WHERE user_id = ? AND provider = ?",
args: [userId, provider]
});
}
/**
* Check if a user has a specific provider linked
* @param userId - User ID
* @param provider - Provider to check
* @returns true if linked, false otherwise
*/
export async function hasProvider(
userId: string,
provider: "email" | "google" | "github"
): Promise<boolean> {
const conn = ConnectionFactory();
const result = await conn.execute({
sql: "SELECT id FROM UserProvider WHERE user_id = ? AND provider = ?",
args: [userId, provider]
});
return result.rows.length > 0;
}
/**
* Get provider summary for a user (for display purposes)
* @param userId - User ID
* @returns Summary of linked providers
*/
export async function getProviderSummary(userId: string): Promise<{
providers: Array<{
provider: string;
email?: string;
displayName?: string;
lastUsed: string;
}>;
count: number;
}> {
const providers = await getUserProviders(userId);
return {
providers: providers.map((p) => ({
provider: p.provider,
email: p.email || undefined,
displayName: p.display_name || undefined,
lastUsed: p.last_used_at
})),
count: providers.length
};
}

View File

@@ -14,6 +14,7 @@ import { AUTH_CONFIG, expiryToSeconds } from "~/config";
import { logAuditEvent } from "./audit";
import type { SessionData } from "./session-config";
import { sessionConfig } from "./session-config";
import { getDeviceInfo } from "./device-utils";
/**
* Generate a cryptographically secure refresh token
@@ -61,6 +62,9 @@ export async function createAuthSession(
const refreshToken = generateRefreshToken();
const tokenHash = hashRefreshToken(refreshToken);
// Parse device information
const deviceInfo = getDeviceInfo(event);
// Calculate refresh token expiration
const refreshExpiry = rememberMe
? AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_LONG
@@ -102,12 +106,13 @@ export async function createAuthSession(
}
}
// Insert session into database
// Insert session into database with device metadata
await conn.execute({
sql: `INSERT INTO Session
(id, user_id, token_family, refresh_token_hash, parent_session_id,
rotation_count, expires_at, access_token_expires_at, ip_address, user_agent)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
rotation_count, expires_at, access_token_expires_at, ip_address, user_agent,
device_name, device_type, browser, os, last_active_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
args: [
sessionId,
userId,
@@ -118,7 +123,11 @@ export async function createAuthSession(
expiresAt.toISOString(),
accessExpiresAt.toISOString(),
ipAddress,
userAgent
userAgent,
deviceInfo.deviceName || null,
deviceInfo.deviceType || null,
deviceInfo.browser || null,
deviceInfo.os || null
]
});
@@ -152,7 +161,9 @@ export async function createAuthSession(
sessionId,
tokenFamily: family,
rememberMe,
parentSessionId
parentSessionId,
deviceName: deviceInfo.deviceName,
deviceType: deviceInfo.deviceType
},
success: true
});
@@ -299,14 +310,14 @@ async function validateSessionInDB(
return false;
}
// Update last_used timestamp (fire and forget)
// Update last_used and last_active_at timestamps (fire and forget)
conn
.execute({
sql: "UPDATE Session SET last_used = datetime('now') WHERE id = ?",
sql: "UPDATE Session SET last_used = datetime('now'), last_active_at = datetime('now') WHERE id = ?",
args: [sessionId]
})
.catch((err) =>
console.error("Failed to update session last_used:", err)
console.error("Failed to update session timestamps:", err)
);
return true;

View File

@@ -0,0 +1,195 @@
import { ConnectionFactory } from "./database";
import type { Session } from "~/db/types";
import { formatDeviceDescription } from "./device-utils";
/**
* Get all active sessions for a user
* @param userId - User ID
* @returns Array of active sessions with formatted device info
*/
export async function getUserActiveSessions(userId: string): Promise<
Array<{
sessionId: string;
deviceDescription: string;
deviceType?: string;
browser?: string;
os?: string;
ipAddress?: string;
lastActive: string;
createdAt: string;
current: boolean;
}>
> {
const conn = ConnectionFactory();
const result = await conn.execute({
sql: `SELECT
id, device_name, device_type, browser, os,
ip_address, last_active_at, created_at, token_family
FROM Session
WHERE user_id = ? AND revoked = 0 AND expires_at > datetime('now')
ORDER BY last_active_at DESC`,
args: [userId]
});
return result.rows.map((row: any) => {
const deviceInfo = {
deviceName: row.device_name,
deviceType: row.device_type,
browser: row.browser,
os: row.os
};
return {
sessionId: row.id,
deviceDescription: formatDeviceDescription(deviceInfo),
deviceType: row.device_type,
browser: row.browser,
os: row.os,
ipAddress: row.ip_address,
lastActive: row.last_active_at,
createdAt: row.created_at,
current: false // Will be set by caller if needed
};
});
}
/**
* Revoke a specific session (not entire token family)
* Useful for "logout from this device" functionality
* @param userId - User ID (for verification)
* @param sessionId - Session ID to revoke
* @throws Error if session not found or doesn't belong to user
*/
export async function revokeUserSession(
userId: string,
sessionId: string
): Promise<void> {
const conn = ConnectionFactory();
// Verify session belongs to user
const verifyResult = await conn.execute({
sql: "SELECT user_id FROM Session WHERE id = ?",
args: [sessionId]
});
if (verifyResult.rows.length === 0) {
throw new Error("Session not found");
}
const sessionUserId = (verifyResult.rows[0] as any).user_id;
if (sessionUserId !== userId) {
throw new Error("Session does not belong to this user");
}
// Revoke the session
await conn.execute({
sql: "UPDATE Session SET revoked = 1 WHERE id = ?",
args: [sessionId]
});
}
/**
* Revoke all sessions for a user EXCEPT the current one
* Useful for "logout from all other devices"
* @param userId - User ID
* @param currentSessionId - Current session ID to keep active
* @returns Number of sessions revoked
*/
export async function revokeOtherUserSessions(
userId: string,
currentSessionId: string
): Promise<number> {
const conn = ConnectionFactory();
const result = await conn.execute({
sql: "UPDATE Session SET revoked = 1 WHERE user_id = ? AND id != ? AND revoked = 0",
args: [userId, currentSessionId]
});
return (result as any).rowsAffected || 0;
}
/**
* Get session count by device type for a user
* @param userId - User ID
* @returns Object with counts by device type
*/
export async function getSessionCountByDevice(userId: string): Promise<{
desktop: number;
mobile: number;
tablet: number;
unknown: number;
total: number;
}> {
const conn = ConnectionFactory();
const result = await conn.execute({
sql: `SELECT
device_type,
COUNT(*) as count
FROM Session
WHERE user_id = ? AND revoked = 0 AND expires_at > datetime('now')
GROUP BY device_type`,
args: [userId]
});
const counts = {
desktop: 0,
mobile: 0,
tablet: 0,
unknown: 0,
total: 0
};
for (const row of result.rows) {
const deviceType = (row as any).device_type;
const count = (row as any).count;
if (deviceType === "desktop") {
counts.desktop = count;
} else if (deviceType === "mobile") {
counts.mobile = count;
} else if (deviceType === "tablet") {
counts.tablet = count;
} else {
counts.unknown = count;
}
counts.total += count;
}
return counts;
}
/**
* Check if a specific device fingerprint already has an active session
* Can be used to show "You're already logged in on this device" messages
* @param userId - User ID
* @param deviceType - Device type
* @param browser - Browser name
* @param os - OS name
* @returns true if device has active session
*/
export async function hasActiveSessionOnDevice(
userId: string,
deviceType?: string,
browser?: string,
os?: string
): Promise<boolean> {
const conn = ConnectionFactory();
const result = await conn.execute({
sql: `SELECT id FROM Session
WHERE user_id = ?
AND device_type = ?
AND browser = ?
AND os = ?
AND revoked = 0
AND expires_at > datetime('now')
LIMIT 1`,
args: [userId, deviceType || null, browser || null, os || null]
});
return result.rows.length > 0;
}