mirror of
https://github.com/ramvignesh-b/pi-ku.git
synced 2026-05-04 08:56:52 +00:00
feat: implement auth state management with RouteGuards
This commit is contained in:
+58
-20
@@ -1,33 +1,27 @@
|
||||
import { useEffect } from "react";
|
||||
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||
import Logo from "./components/Logo";
|
||||
import { ProtectedRoute, PublicRoute } from "./components/RouteGuards";
|
||||
import SplashScreen from "./components/SplashScreen";
|
||||
import { ROUTES } from "./config/routes";
|
||||
import { useAuth } from "./hooks/useAuth";
|
||||
import Activate from "./pages/Activate";
|
||||
import Drawer from "./pages/Drawer";
|
||||
import Editor from "./pages/Editor";
|
||||
// Pages
|
||||
import Home from "./pages/Home";
|
||||
import Login from "./pages/Login";
|
||||
import Register from "./pages/Register";
|
||||
import VerifyEmail from "./pages/VerifyEmail";
|
||||
import { useAuth } from "./store/useAuth";
|
||||
|
||||
export default function App() {
|
||||
const { checkAuth, isInitializing } = useAuth();
|
||||
const { initialize, isInitializing } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
}, [checkAuth]);
|
||||
initialize();
|
||||
}, [initialize]);
|
||||
|
||||
if (isInitializing) {
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200 flex flex-col items-center justify-center gap-4">
|
||||
<Logo />
|
||||
<span className="loading loading-dots loading-lg text-primary"></span>
|
||||
<p className="text-sm font-medium opacity-50 uppercase tracking-widest">
|
||||
LOADING...
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
return <SplashScreen />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -35,12 +29,56 @@ export default function App() {
|
||||
<main className="min-h-screen bg-base-200 flex items-center justify-center w-full">
|
||||
<Routes>
|
||||
<Route path={ROUTES.HOME} element={<Home />} />
|
||||
<Route path={ROUTES.ONBOARD} element={<Register />} />
|
||||
<Route path={ROUTES.VERIFY_EMAIL} element={<VerifyEmail />} />
|
||||
<Route path={ROUTES.ACTIVATE} element={<Activate />} />
|
||||
<Route path={ROUTES.LOGIN} element={<Login />} />
|
||||
<Route path={ROUTES.DRAWER} element={<Drawer />} />
|
||||
<Route path={ROUTES.WRITE} element={<Editor />} />
|
||||
|
||||
<Route
|
||||
path={ROUTES.ONBOARD}
|
||||
element={
|
||||
<PublicRoute>
|
||||
<Register />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={ROUTES.LOGIN}
|
||||
element={
|
||||
<PublicRoute>
|
||||
<Login />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={ROUTES.VERIFY_EMAIL}
|
||||
element={
|
||||
<PublicRoute>
|
||||
<VerifyEmail />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={ROUTES.ACTIVATE}
|
||||
element={
|
||||
<PublicRoute>
|
||||
<Activate />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path={ROUTES.DRAWER}
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Drawer />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={ROUTES.WRITE}
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Editor />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to={ROUTES.HOME} replace />} />
|
||||
</Routes>
|
||||
</main>
|
||||
|
||||
@@ -1,64 +1,56 @@
|
||||
import axios, { type AxiosError } from "axios";
|
||||
import axios from "axios";
|
||||
import { endpoints } from "../config/endpoints";
|
||||
import { useAuth } from "../store/useAuth";
|
||||
import { useAuthStore } from "../store/useAuth";
|
||||
|
||||
const baseURL = import.meta.env.VITE_API_URL;
|
||||
|
||||
export const preAuthApiClient = axios.create({
|
||||
baseURL,
|
||||
// publicApi for endpoints that don't need authentication (login, refresh, register)
|
||||
export const publicApi = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL,
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
export const postAuthApiClient = axios.create({
|
||||
baseURL,
|
||||
// api for all authenticated requests
|
||||
export const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL,
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
// automatically attach access token to requests
|
||||
postAuthApiClient.interceptors.request.use((config) => {
|
||||
const token = useAuth.getState().accessToken;
|
||||
// auto-attach access token to authenticated requests
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = useAuthStore.getState().accessToken;
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// handle 401 & auto token refresh
|
||||
postAuthApiClient.interceptors.response.use(
|
||||
// Handle 401 errors by attempting a silent refresh
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError) => {
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// do not retry refresh request
|
||||
if (
|
||||
error.response?.status === 401 &&
|
||||
originalRequest &&
|
||||
!originalRequest.url?.includes(endpoints.REFRESH)
|
||||
) {
|
||||
// If 401 and we haven't tried refreshing yet
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
// refresh the access token
|
||||
const response = await preAuthApiClient.post(endpoints.REFRESH);
|
||||
// Attempt silent refresh
|
||||
const { data } = await publicApi.post(endpoints.REFRESH);
|
||||
const newAccessToken = data.access;
|
||||
|
||||
if (response.status === 200) {
|
||||
const newAccessToken = response.data.access;
|
||||
|
||||
// update the auth store so the retry uses the new token
|
||||
useAuth.setState({
|
||||
accessToken: newAccessToken,
|
||||
isAuthenticated: true,
|
||||
});
|
||||
|
||||
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
|
||||
return postAuthApiClient(originalRequest);
|
||||
// Update store
|
||||
const { user, setAuth } = useAuthStore.getState();
|
||||
if (user) {
|
||||
setAuth(newAccessToken, user);
|
||||
}
|
||||
|
||||
// Retry the original request with the new token
|
||||
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
|
||||
return api(originalRequest);
|
||||
} catch (refreshError) {
|
||||
useAuth.getState().logout();
|
||||
// Refresh failed, perform logout to clear tokens
|
||||
console.error("Session expired, logging out...");
|
||||
useAuthStore.getState().clearAuth();
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Navigate, useLocation } from "react-router-dom";
|
||||
import { ROUTES } from "../config/routes";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
import SplashScreen from "./SplashScreen";
|
||||
|
||||
/**
|
||||
* Post-login routes.
|
||||
* Redirects to /login if not already authenticated.
|
||||
*/
|
||||
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isInitializing } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (isInitializing) return <SplashScreen />;
|
||||
|
||||
if (!isAuthenticated) {
|
||||
// Save the intended location to redirect back after login
|
||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-login flows.
|
||||
* Redirects to /drawer if already authenticated.
|
||||
*/
|
||||
export function PublicRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isInitializing } = useAuth();
|
||||
|
||||
if (isInitializing) return <SplashScreen />;
|
||||
|
||||
if (isAuthenticated) {
|
||||
return <Navigate to={ROUTES.DRAWER} replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import Logo from "./Logo";
|
||||
|
||||
export default function SplashScreen() {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-base-100 flex flex-col items-center justify-center z-9999">
|
||||
<div className="flex flex-col items-center gap-6 animate-pulse">
|
||||
<Logo />
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span className="loading loading-ring loading-lg text-primary" />
|
||||
<p className="text-xs uppercase tracking-widest opacity-40 font-display">
|
||||
Initializing Identity
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { api, publicApi } from "../api/apiClient";
|
||||
import { endpoints } from "../config/endpoints";
|
||||
import { useAuthStore } from "../store/useAuth";
|
||||
|
||||
interface UserProfile {
|
||||
public_id: string;
|
||||
email: string;
|
||||
full_name: string;
|
||||
}
|
||||
|
||||
export const useAuth = () => {
|
||||
const {
|
||||
accessToken,
|
||||
user,
|
||||
isInitializing,
|
||||
setAuth,
|
||||
clearAuth,
|
||||
setInitializing,
|
||||
} = useAuthStore();
|
||||
|
||||
const isAuthenticated = !!accessToken;
|
||||
|
||||
const login = async (access: string, profile: UserProfile) => {
|
||||
setAuth(access, profile);
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await api.post(endpoints.LOGOUT);
|
||||
} finally {
|
||||
clearAuth();
|
||||
}
|
||||
};
|
||||
|
||||
const initialize = async () => {
|
||||
// If we already have a session in memory, don't trigger refresh/me again
|
||||
if (accessToken && user) {
|
||||
setInitializing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// try refresh
|
||||
const { data: refreshData } = await publicApi.post(endpoints.REFRESH);
|
||||
|
||||
// fetch user profile with the new access token
|
||||
const { data: userData } = await api.get(endpoints.ME, {
|
||||
headers: { Authorization: `Bearer ${refreshData.access}` },
|
||||
});
|
||||
|
||||
// update auth details in memory
|
||||
setAuth(refreshData.access, userData);
|
||||
} catch (err) {
|
||||
console.error("Initialization failed:", err);
|
||||
clearAuth();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isAuthenticated,
|
||||
user,
|
||||
isInitializing,
|
||||
login,
|
||||
logout,
|
||||
initialize,
|
||||
};
|
||||
};
|
||||
@@ -11,7 +11,9 @@
|
||||
--color-base-100: oklch(14% 0.012 35); /* was 0.018 hue 50 */
|
||||
--color-base-200: oklch(18% 0.014 33);
|
||||
--color-base-300: oklch(22% 0.016 32);
|
||||
--color-base-content: oklch(82% 0.02 70); /* aged parchment, not crisp white */
|
||||
--color-base-content: oklch(
|
||||
82% 0.02 70
|
||||
); /* aged parchment, not crisp white */
|
||||
|
||||
/* ── Primary: old lamp gold — warm, incandescent ── */
|
||||
--color-primary: oklch(67% 0.11 78);
|
||||
@@ -56,7 +58,12 @@
|
||||
--font-display: "Playwrite HR Lijeva Variable", cursive;
|
||||
--font-sans: "Jost Variable", sans-serif;
|
||||
--font-serif: "Playfair Display Variable", serif;
|
||||
--color-glass-bg: rgba(28, 22, 16, 0.45); /* slightly deeper to match new base */
|
||||
--color-glass-bg: rgba(
|
||||
28,
|
||||
22,
|
||||
16,
|
||||
0.45
|
||||
); /* slightly deeper to match new base */
|
||||
--shadow-warm: 0 20px 50px -12px rgba(30, 20, 12, 0.6);
|
||||
--radius-xl: 1.5rem;
|
||||
--color-paper: oklch(97% 0.008 80);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CheckCircleIcon, XCircleIcon } from "@phosphor-icons/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { preAuthApiClient } from "../api/apiClient";
|
||||
import { publicApi } from "../api/apiClient";
|
||||
import Logo from "../components/Logo";
|
||||
import { endpoints, replacePathParams } from "../config/endpoints";
|
||||
import { ROUTES } from "../config/routes";
|
||||
@@ -26,7 +26,7 @@ export default function Activate() {
|
||||
uidb64,
|
||||
token,
|
||||
});
|
||||
await preAuthApiClient.get(url);
|
||||
await publicApi.get(url);
|
||||
setStatus("success");
|
||||
} catch (err) {
|
||||
console.error("Activation error:", err);
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../store/useAuth";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
|
||||
export default function Drawer() {
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
navigate("/login");
|
||||
}
|
||||
}, [isAuthenticated, navigate]);
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="glass-card w-full max-w-sm p-8 text-center fade-zoom"></div>
|
||||
<div className="glass-card w-full max-w-sm p-8 text-center flex flex-col gap-6 fade-zoom">
|
||||
<div className="space-y-2">
|
||||
<h1 className="font-display text-2xl font-bold text-primary">
|
||||
Your Drawer
|
||||
</h1>
|
||||
<p className="text-sm opacity-70">Welcome back, {user.full_name}</p>
|
||||
</div>
|
||||
|
||||
<div className="divider opacity-10" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
className="btn btn-link btn-xs opacity-40 hover:opacity-100 no-underline transition-all"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { LockIcon, TrayIcon } from "@phosphor-icons/react";
|
||||
import { useState } from "react";
|
||||
import ComposeCanvas from "../components/ui/ComposeCanvas";
|
||||
import DateDisplay from "../components/ui/DateDisplay";
|
||||
@@ -30,9 +31,30 @@ export default function Editor() {
|
||||
|
||||
<div
|
||||
id="writer-toolbar"
|
||||
className="flex items-center justify-center mb-4 min-h-12 bg-white/5 rounded-sm border border-white/5 font-display text-sm tracking-widest text-secondary-content"
|
||||
className="flex items-center justify-between mb-8 h-14 bg-base-100/50 backdrop-blur-md rounded-full border border-base-content/5 px-6"
|
||||
>
|
||||
Toolbar Placeholder
|
||||
<div className="flex gap-4">Toolbar Placeholder</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm text-[10px] tracking-[0.2em] uppercase font-bold text-base-content/60 hover:text-base-content"
|
||||
title="Keep in your private drawer"
|
||||
>
|
||||
<TrayIcon size={18} weight="bold" />
|
||||
<span className="hidden md:inline">Keep</span>
|
||||
</button>
|
||||
|
||||
<div className="w-px h-4 bg-base-content/10 mx-2" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm rounded-full px-6 text-[10px] tracking-[0.3em] uppercase font-black"
|
||||
>
|
||||
<LockIcon size={14} weight="fill" className="mr-1" />
|
||||
Seal
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ComposeCanvas />
|
||||
</div>
|
||||
|
||||
@@ -4,13 +4,15 @@ import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { z } from "zod";
|
||||
import { api, publicApi } from "../api/apiClient";
|
||||
import Logo from "../components/Logo";
|
||||
import FormField from "../components/ui/FormField";
|
||||
import { endpoints } from "../config/endpoints";
|
||||
import { ROUTES } from "../config/routes";
|
||||
import { useAuth } from "../store/useAuth";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.email("Please enter a valid email"),
|
||||
email: z.string().email("Please enter a valid email"),
|
||||
password: z.string().min(1, "Password is required"),
|
||||
});
|
||||
|
||||
@@ -20,7 +22,7 @@ export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
const login = useAuth((state) => state.login);
|
||||
const { login } = useAuth();
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -34,7 +36,18 @@ export default function Login() {
|
||||
setIsLoading(true);
|
||||
setApiError(null);
|
||||
try {
|
||||
await login(data);
|
||||
// 1. Authenticate
|
||||
const { data: authData } = await publicApi.post(endpoints.LOGIN, data);
|
||||
|
||||
// 2. Fetch User Profile with the fresh token
|
||||
// We pass the header explicitly to avoid any race conditions with interceptors
|
||||
const { data: userData } = await api.get(endpoints.ME, {
|
||||
headers: { Authorization: `Bearer ${authData.access}` },
|
||||
});
|
||||
|
||||
// 3. Update store using the hook method
|
||||
await login(authData.access, userData);
|
||||
|
||||
navigate(ROUTES.DRAWER);
|
||||
} catch (err) {
|
||||
console.error("Login error:", err);
|
||||
@@ -49,6 +62,7 @@ export default function Login() {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glass-card w-full max-w-sm p-2 transition-all duration-500 hover:shadow-2xl fade-zoom">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="card-body gap-4">
|
||||
<h1 className="card-title font-display text-2xl font-bold justify-center text-primary tracking-tight">
|
||||
@@ -103,5 +117,6 @@ export default function Login() {
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>;
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { z } from "zod";
|
||||
import { preAuthApiClient } from "../api/apiClient";
|
||||
import { publicApi } from "../api/apiClient";
|
||||
import Logo from "../components/Logo";
|
||||
import FormField from "../components/ui/FormField";
|
||||
import { endpoints } from "../config/endpoints";
|
||||
@@ -42,7 +42,7 @@ export default function Register() {
|
||||
setIsLoading(true);
|
||||
setApiError(null);
|
||||
try {
|
||||
await preAuthApiClient.post(endpoints.REGISTER, {
|
||||
await publicApi.post(endpoints.REGISTER, {
|
||||
full_name: data.full_name,
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { create } from "zustand";
|
||||
import { postAuthApiClient, preAuthApiClient } from "../api/apiClient";
|
||||
import { endpoints } from "../config/endpoints";
|
||||
|
||||
interface UserProfile {
|
||||
public_id: string;
|
||||
@@ -10,59 +8,20 @@ interface UserProfile {
|
||||
|
||||
interface AuthState {
|
||||
accessToken: string | null;
|
||||
isAuthenticated: boolean;
|
||||
user: UserProfile | null;
|
||||
isInitializing: boolean; // refresh in transit
|
||||
login: (credentials: any) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
checkAuth: () => Promise<void>;
|
||||
isInitializing: boolean;
|
||||
setAuth: (accessToken: string, user: UserProfile) => void;
|
||||
clearAuth: () => void;
|
||||
setInitializing: (v: boolean) => void;
|
||||
}
|
||||
|
||||
export const useAuth = create<AuthState>((set, get) => ({
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
accessToken: null,
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
isInitializing: true,
|
||||
|
||||
login: async (credentials: any) => {
|
||||
const response = await preAuthApiClient.post(endpoints.LOGIN, credentials);
|
||||
set({
|
||||
accessToken: response.data.access,
|
||||
isAuthenticated: true,
|
||||
user: response.data.user,
|
||||
});
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
try {
|
||||
const token = get().accessToken;
|
||||
if (token) {
|
||||
await preAuthApiClient.post(endpoints.LOGOUT);
|
||||
}
|
||||
} finally {
|
||||
set({
|
||||
accessToken: null,
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
checkAuth: async () => {
|
||||
try {
|
||||
const response = await postAuthApiClient.get(endpoints.ME);
|
||||
set({
|
||||
user: response.data,
|
||||
isAuthenticated: true,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Check auth error:", err);
|
||||
set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
} finally {
|
||||
set({ isInitializing: false });
|
||||
}
|
||||
},
|
||||
setAuth: (accessToken, user) =>
|
||||
set({ accessToken, user, isInitializing: false }),
|
||||
clearAuth: () =>
|
||||
set({ accessToken: null, user: null, isInitializing: false }),
|
||||
setInitializing: (isInitializing) => set({ isInitializing }),
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 0 knowledge cryptography. No Server involved in encryption/decryption
|
||||
*/
|
||||
|
||||
const ITERATIONS = 100000;
|
||||
const KEY_ALGO = { name: "AES-GCM", length: 256 };
|
||||
|
||||
/**
|
||||
* Derives a Master Encryption Key from a password and email (salt).
|
||||
*/
|
||||
export async function deriveMasterKey(
|
||||
password: string,
|
||||
email: string,
|
||||
): Promise<CryptoKey> {
|
||||
const encoder = new TextEncoder();
|
||||
const passwordKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
encoder.encode(password),
|
||||
"PBKDF2",
|
||||
false,
|
||||
["deriveKey"],
|
||||
);
|
||||
|
||||
return crypto.subtle.deriveKey(
|
||||
{
|
||||
name: "PBKDF2",
|
||||
salt: encoder.encode(email.toLowerCase()),
|
||||
iterations: ITERATIONS,
|
||||
hash: "SHA-256",
|
||||
},
|
||||
passwordKey,
|
||||
KEY_ALGO,
|
||||
false,
|
||||
["encrypt", "decrypt", "wrapKey", "unwrapKey"],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts a letter using Envelope Encryption.
|
||||
*/
|
||||
export async function encryptLetter(plaintext: string, masterKey: CryptoKey) {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
// Generate random Data Encryption Key (DEK)
|
||||
const dek = await crypto.subtle.generateKey(KEY_ALGO, true, [
|
||||
"encrypt",
|
||||
"decrypt",
|
||||
]);
|
||||
|
||||
// Encrypt the content with the DEK
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv },
|
||||
dek,
|
||||
encoder.encode(plaintext),
|
||||
);
|
||||
|
||||
// encrpyt the DEK using the Master Key for the self access
|
||||
const keyIv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const wrappedKey = await crypto.subtle.wrapKey("raw", dek, masterKey, {
|
||||
name: "AES-GCM",
|
||||
iv: keyIv,
|
||||
});
|
||||
|
||||
// for recipients (link share), export DEK in raw format
|
||||
const rawKey = await crypto.subtle.exportKey("raw", dek);
|
||||
|
||||
// conversion to base64 for transit
|
||||
const toBase64 = (buffer: Uint8Array) => btoa(String.fromCharCode(...buffer));
|
||||
|
||||
return {
|
||||
// This goes to the server
|
||||
encryptedPayload: {
|
||||
ciphertext: toBase64(new Uint8Array(ciphertext)),
|
||||
iv: toBase64(new Uint8Array(iv)),
|
||||
wrappedKey: toBase64(new Uint8Array(wrappedKey)),
|
||||
keyIv: toBase64(new Uint8Array(keyIv)),
|
||||
},
|
||||
// This goes into the url for the recipient
|
||||
sharingKey: toBase64(new Uint8Array(rawKey)),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user