forked from
tranquil.farm/tranquil-pds
Our Personal Data Server from scratch!
1import { api, ApiError, castSession, typedApi } from "./api.ts";
2import type {
3 CreateAccountParams,
4 CreateAccountResult,
5 Session,
6} from "./types/api.ts";
7import {
8 type AccessToken,
9 type Did,
10 type Handle,
11 type RefreshToken,
12 unsafeAsAccessToken,
13 unsafeAsDid,
14 unsafeAsHandle,
15 unsafeAsRefreshToken,
16} from "./types/branded.ts";
17import { err, isErr, isOk, ok, type Result } from "./types/result.ts";
18import { assertNever } from "./types/exhaustive.ts";
19import {
20 checkForOAuthCallback,
21 clearAllOAuthState,
22 clearOAuthCallbackParams,
23 handleOAuthCallback,
24 refreshOAuthToken,
25 startOAuthLogin,
26} from "./oauth.ts";
27import { setLocale, type SupportedLocale } from "./i18n.ts";
28
29const STORAGE_KEY = "tranquil_pds_session";
30const ACCOUNTS_KEY = "tranquil_pds_accounts";
31
32export interface SavedAccount {
33 readonly did: Did;
34 readonly handle: Handle;
35 readonly accessJwt: AccessToken;
36 readonly refreshJwt: RefreshToken;
37}
38
39export type AuthError =
40 | { readonly type: "network"; readonly message: string }
41 | { readonly type: "unauthorized"; readonly message: string }
42 | { readonly type: "validation"; readonly message: string }
43 | { readonly type: "oauth"; readonly message: string }
44 | { readonly type: "unknown"; readonly message: string };
45
46function toAuthError(e: unknown): AuthError {
47 if (e instanceof ApiError) {
48 if (e.status === 401) {
49 return { type: "unauthorized", message: e.message };
50 }
51 return { type: "validation", message: e.message };
52 }
53 if (e instanceof Error) {
54 if (e.message.includes("network") || e.message.includes("fetch")) {
55 return { type: "network", message: e.message };
56 }
57 return { type: "unknown", message: e.message };
58 }
59 return { type: "unknown", message: "An unknown error occurred" };
60}
61
62type AuthStateKind = "unauthenticated" | "loading" | "authenticated" | "error";
63
64export type AuthState =
65 | {
66 readonly kind: "unauthenticated";
67 readonly savedAccounts: readonly SavedAccount[];
68 }
69 | {
70 readonly kind: "loading";
71 readonly savedAccounts: readonly SavedAccount[];
72 readonly previousSession: Session | null;
73 }
74 | {
75 readonly kind: "authenticated";
76 readonly session: Session;
77 readonly savedAccounts: readonly SavedAccount[];
78 }
79 | {
80 readonly kind: "error";
81 readonly error: AuthError;
82 readonly savedAccounts: readonly SavedAccount[];
83 };
84
85function createUnauthenticated(
86 savedAccounts: readonly SavedAccount[],
87): AuthState {
88 return { kind: "unauthenticated", savedAccounts };
89}
90
91function createLoading(
92 savedAccounts: readonly SavedAccount[],
93 previousSession: Session | null = null,
94): AuthState {
95 return { kind: "loading", savedAccounts, previousSession };
96}
97
98function createAuthenticated(
99 session: Session,
100 savedAccounts: readonly SavedAccount[],
101): AuthState {
102 return { kind: "authenticated", session, savedAccounts };
103}
104
105function createError(
106 error: AuthError,
107 savedAccounts: readonly SavedAccount[],
108): AuthState {
109 return { kind: "error", error, savedAccounts };
110}
111
112const state = $state<{ current: AuthState }>({
113 current: createLoading([]),
114});
115
116function applyLocaleFromSession(sessionInfo: {
117 preferredLocale?: string | null;
118}): void {
119 if (sessionInfo.preferredLocale) {
120 setLocale(sessionInfo.preferredLocale as SupportedLocale);
121 }
122}
123
124function sessionToSavedAccount(session: Session): SavedAccount {
125 return {
126 did: unsafeAsDid(session.did),
127 handle: unsafeAsHandle(session.handle),
128 accessJwt: unsafeAsAccessToken(session.accessJwt),
129 refreshJwt: unsafeAsRefreshToken(session.refreshJwt),
130 };
131}
132
133interface StoredSession {
134 readonly did: string;
135 readonly handle: string;
136 readonly accessJwt: string;
137 readonly refreshJwt: string;
138 readonly email?: string;
139 readonly emailConfirmed?: boolean;
140 readonly preferredChannel?: string;
141 readonly preferredChannelVerified?: boolean;
142 readonly preferredLocale?: string | null;
143}
144
145function parseStoredSession(json: string): Result<StoredSession, Error> {
146 try {
147 const parsed = JSON.parse(json);
148 if (
149 typeof parsed === "object" &&
150 parsed !== null &&
151 typeof parsed.did === "string" &&
152 typeof parsed.handle === "string" &&
153 typeof parsed.accessJwt === "string" &&
154 typeof parsed.refreshJwt === "string"
155 ) {
156 return ok(parsed as StoredSession);
157 }
158 return err(new Error("Invalid session format"));
159 } catch (e) {
160 return err(e instanceof Error ? e : new Error("Failed to parse session"));
161 }
162}
163
164function parseStoredAccounts(json: string): Result<SavedAccount[], Error> {
165 try {
166 const parsed = JSON.parse(json);
167 if (!Array.isArray(parsed)) {
168 return err(new Error("Invalid accounts format"));
169 }
170 const accounts: SavedAccount[] = parsed
171 .filter(
172 (
173 a,
174 ): a is {
175 did: string;
176 handle: string;
177 accessJwt: string;
178 refreshJwt: string;
179 } =>
180 typeof a === "object" &&
181 a !== null &&
182 typeof a.did === "string" &&
183 typeof a.handle === "string" &&
184 typeof a.accessJwt === "string" &&
185 typeof a.refreshJwt === "string",
186 )
187 .map((a) => ({
188 did: unsafeAsDid(a.did),
189 handle: unsafeAsHandle(a.handle),
190 accessJwt: unsafeAsAccessToken(a.accessJwt),
191 refreshJwt: unsafeAsRefreshToken(a.refreshJwt),
192 }));
193 return ok(accounts);
194 } catch (e) {
195 return err(e instanceof Error ? e : new Error("Failed to parse accounts"));
196 }
197}
198
199function loadSessionFromStorage(): StoredSession | null {
200 const stored = localStorage.getItem(STORAGE_KEY);
201 if (!stored) return null;
202 const result = parseStoredSession(stored);
203 return isOk(result) ? result.value : null;
204}
205
206function loadSavedAccountsFromStorage(): readonly SavedAccount[] {
207 const stored = localStorage.getItem(ACCOUNTS_KEY);
208 if (!stored) return [];
209 const result = parseStoredAccounts(stored);
210 return isOk(result) ? result.value : [];
211}
212
213function persistSession(session: Session | null): void {
214 if (session) {
215 localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
216 } else {
217 localStorage.removeItem(STORAGE_KEY);
218 }
219}
220
221function persistSavedAccounts(accounts: readonly SavedAccount[]): void {
222 localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts));
223}
224
225function updateSavedAccounts(
226 accounts: readonly SavedAccount[],
227 session: Session,
228): readonly SavedAccount[] {
229 const newAccount = sessionToSavedAccount(session);
230 const filtered = accounts.filter((a) => a.did !== newAccount.did);
231 return [...filtered, newAccount];
232}
233
234function removeSavedAccountByDid(
235 accounts: readonly SavedAccount[],
236 did: Did,
237): readonly SavedAccount[] {
238 return accounts.filter((a) => a.did !== did);
239}
240
241function findSavedAccount(
242 accounts: readonly SavedAccount[],
243 did: Did,
244): SavedAccount | undefined {
245 return accounts.find((a) => a.did === did);
246}
247
248function getSavedAccounts(): readonly SavedAccount[] {
249 return state.current.savedAccounts;
250}
251
252function setState(newState: AuthState): void {
253 state.current = newState;
254}
255
256function setAuthenticated(session: Session): void {
257 const accounts = updateSavedAccounts(getSavedAccounts(), session);
258 persistSession(session);
259 persistSavedAccounts(accounts);
260 setState(createAuthenticated(session, accounts));
261}
262
263function setUnauthenticated(): void {
264 persistSession(null);
265 setState(createUnauthenticated(getSavedAccounts()));
266}
267
268function setError(error: AuthError): void {
269 setState(createError(error, getSavedAccounts()));
270}
271
272function setLoading(previousSession: Session | null = null): void {
273 setState(createLoading(getSavedAccounts(), previousSession));
274}
275
276export function clearError(): void {
277 if (state.current.kind === "error") {
278 setState(createUnauthenticated(getSavedAccounts()));
279 }
280}
281
282async function tryRefreshToken(): Promise<AccessToken | null> {
283 if (state.current.kind !== "authenticated") return null;
284 const currentSession = state.current.session;
285 try {
286 const tokens = await refreshOAuthToken(currentSession.refreshJwt);
287 const sessionInfo = await api.getSession(
288 unsafeAsAccessToken(tokens.access_token),
289 );
290 const session: Session = {
291 ...sessionInfo,
292 accessJwt: unsafeAsAccessToken(tokens.access_token),
293 refreshJwt: tokens.refresh_token
294 ? unsafeAsRefreshToken(tokens.refresh_token)
295 : currentSession.refreshJwt,
296 };
297 setAuthenticated(session);
298 return session.accessJwt;
299 } catch {
300 return null;
301 }
302}
303
304import { setTokenRefreshCallback } from "./api.ts";
305
306export async function initAuth(): Promise<{ oauthLoginCompleted: boolean }> {
307 setTokenRefreshCallback(tryRefreshToken);
308 const savedAccounts = loadSavedAccountsFromStorage();
309 setState(createLoading(savedAccounts));
310
311 const oauthCallback = checkForOAuthCallback();
312 if (oauthCallback) {
313 clearOAuthCallbackParams();
314 try {
315 const tokens = await handleOAuthCallback(
316 oauthCallback.code,
317 oauthCallback.state,
318 );
319 const sessionInfo = await api.getSession(
320 unsafeAsAccessToken(tokens.access_token),
321 );
322 const session: Session = {
323 ...sessionInfo,
324 accessJwt: unsafeAsAccessToken(tokens.access_token),
325 refreshJwt: unsafeAsRefreshToken(tokens.refresh_token || ""),
326 };
327 setAuthenticated(session);
328 applyLocaleFromSession(session);
329 return { oauthLoginCompleted: true };
330 } catch (e) {
331 clearAllOAuthState();
332 setError({
333 type: "oauth",
334 message: e instanceof Error ? e.message : "OAuth login failed",
335 });
336 return { oauthLoginCompleted: false };
337 }
338 }
339
340 const stored = loadSessionFromStorage();
341 if (stored) {
342 try {
343 const sessionInfo = await api.getSession(
344 unsafeAsAccessToken(stored.accessJwt),
345 );
346 const session: Session = {
347 ...sessionInfo,
348 accessJwt: unsafeAsAccessToken(stored.accessJwt),
349 refreshJwt: unsafeAsRefreshToken(stored.refreshJwt),
350 };
351 setAuthenticated(session);
352 applyLocaleFromSession(session);
353 } catch (e) {
354 if (e instanceof ApiError && e.status === 401) {
355 try {
356 const tokens = await refreshOAuthToken(stored.refreshJwt);
357 const sessionInfo = await api.getSession(
358 unsafeAsAccessToken(tokens.access_token),
359 );
360 const session: Session = {
361 ...sessionInfo,
362 accessJwt: unsafeAsAccessToken(tokens.access_token),
363 refreshJwt: tokens.refresh_token
364 ? unsafeAsRefreshToken(tokens.refresh_token)
365 : unsafeAsRefreshToken(stored.refreshJwt),
366 };
367 setAuthenticated(session);
368 applyLocaleFromSession(session);
369 } catch (refreshError) {
370 console.error("Token refresh failed during init:", refreshError);
371 setUnauthenticated();
372 }
373 } else {
374 console.error("Non-401 error during getSession:", e);
375 setUnauthenticated();
376 }
377 }
378 } else {
379 setState(createUnauthenticated(savedAccounts));
380 }
381
382 return { oauthLoginCompleted: false };
383}
384
385export async function login(
386 identifier: string,
387 password: string,
388): Promise<Result<Session, AuthError>> {
389 const currentState = state.current;
390 const previousSession = currentState.kind === "authenticated"
391 ? currentState.session
392 : null;
393 setLoading(previousSession);
394
395 const result = await typedApi.createSession(identifier, password);
396 if (isErr(result)) {
397 const error = toAuthError(result.error);
398 setError(error);
399 return err(error);
400 }
401
402 setAuthenticated(result.value);
403 return ok(result.value);
404}
405
406export async function loginWithOAuth(): Promise<Result<void, AuthError>> {
407 clearAllOAuthState();
408 setLoading();
409 try {
410 await startOAuthLogin();
411 return ok(undefined);
412 } catch (e) {
413 const error = toAuthError(e);
414 setError(error);
415 return err(error);
416 }
417}
418
419export async function register(
420 params: CreateAccountParams,
421): Promise<Result<CreateAccountResult, AuthError>> {
422 try {
423 const result = await api.createAccount(params);
424 return ok(result);
425 } catch (e) {
426 return err(toAuthError(e));
427 }
428}
429
430export async function confirmSignup(
431 did: Did,
432 verificationCode: string,
433): Promise<Result<Session, AuthError>> {
434 setLoading();
435 try {
436 const result = await api.confirmSignup(did, verificationCode);
437 const session = castSession(result);
438 setAuthenticated(session);
439 return ok(session);
440 } catch (e) {
441 const error = toAuthError(e);
442 setError(error);
443 return err(error);
444 }
445}
446
447export async function resendVerification(
448 did: Did,
449): Promise<Result<void, AuthError>> {
450 try {
451 await api.resendVerification(did);
452 return ok(undefined);
453 } catch (e) {
454 return err(toAuthError(e));
455 }
456}
457
458export function setSession(session: {
459 did: string;
460 handle: string;
461 accessJwt: string;
462 refreshJwt: string;
463}): void {
464 const newSession: Session = {
465 did: unsafeAsDid(session.did),
466 handle: unsafeAsHandle(session.handle),
467 accessJwt: unsafeAsAccessToken(session.accessJwt),
468 refreshJwt: unsafeAsRefreshToken(session.refreshJwt),
469 contactKind: "none",
470 accountKind: "active",
471 isAdmin: false,
472 };
473 setAuthenticated(newSession);
474}
475
476export async function logout(): Promise<Result<void, AuthError>> {
477 if (state.current.kind === "authenticated") {
478 const { session } = state.current;
479 const did = unsafeAsDid(session.did);
480 try {
481 await fetch("/oauth/revoke", {
482 method: "POST",
483 headers: { "Content-Type": "application/x-www-form-urlencoded" },
484 body: new URLSearchParams({ token: session.refreshJwt }),
485 });
486 } catch {
487 // Ignore revocation errors
488 }
489 const accounts = removeSavedAccountByDid(getSavedAccounts(), did);
490 persistSavedAccounts(accounts);
491 persistSession(null);
492 setState(createUnauthenticated(accounts));
493 } else {
494 setUnauthenticated();
495 }
496 return ok(undefined);
497}
498
499export async function switchAccount(
500 did: Did,
501): Promise<Result<Session, AuthError>> {
502 const account = findSavedAccount(getSavedAccounts(), did);
503 if (!account) {
504 return err({ type: "validation", message: "Account not found" });
505 }
506
507 setLoading();
508
509 try {
510 const sessionInfo = await api.getSession(account.accessJwt);
511 const session: Session = {
512 ...sessionInfo,
513 accessJwt: account.accessJwt,
514 refreshJwt: account.refreshJwt,
515 };
516 setAuthenticated(session);
517 return ok(session);
518 } catch (e) {
519 if (e instanceof ApiError && e.status === 401) {
520 try {
521 const tokens = await refreshOAuthToken(account.refreshJwt);
522 const sessionInfo = await api.getSession(
523 unsafeAsAccessToken(tokens.access_token),
524 );
525 const session: Session = {
526 ...sessionInfo,
527 accessJwt: unsafeAsAccessToken(tokens.access_token),
528 refreshJwt: tokens.refresh_token
529 ? unsafeAsRefreshToken(tokens.refresh_token)
530 : account.refreshJwt,
531 };
532 setAuthenticated(session);
533 return ok(session);
534 } catch {
535 const accounts = removeSavedAccountByDid(getSavedAccounts(), did);
536 persistSavedAccounts(accounts);
537 const error: AuthError = {
538 type: "unauthorized",
539 message: "Session expired. Please log in again.",
540 };
541 setState(createError(error, accounts));
542 return err(error);
543 }
544 }
545 const error = toAuthError(e);
546 setError(error);
547 return err(error);
548 }
549}
550
551export function forgetAccount(did: Did): void {
552 const accounts = removeSavedAccountByDid(getSavedAccounts(), did);
553 persistSavedAccounts(accounts);
554 setState({
555 ...state.current,
556 savedAccounts: accounts,
557 } as AuthState);
558}
559
560export function getAuthState(): AuthState {
561 return state.current;
562}
563
564export async function refreshSession(): Promise<Result<Session, AuthError>> {
565 if (state.current.kind !== "authenticated") {
566 return err({ type: "unauthorized", message: "Not authenticated" });
567 }
568 const currentSession = state.current.session;
569 try {
570 const sessionInfo = await api.getSession(currentSession.accessJwt);
571 const session: Session = {
572 ...sessionInfo,
573 accessJwt: currentSession.accessJwt,
574 refreshJwt: currentSession.refreshJwt,
575 };
576 setAuthenticated(session);
577 return ok(session);
578 } catch (e) {
579 console.error("Failed to refresh session:", e);
580 return err(toAuthError(e));
581 }
582}
583
584export function getToken(): AccessToken | null {
585 if (state.current.kind === "authenticated") {
586 return state.current.session.accessJwt;
587 }
588 return null;
589}
590
591export async function getValidToken(): Promise<AccessToken | null> {
592 if (state.current.kind !== "authenticated") return null;
593 const currentSession = state.current.session;
594 try {
595 await api.getSession(currentSession.accessJwt);
596 return currentSession.accessJwt;
597 } catch (e) {
598 if (e instanceof ApiError && e.status === 401) {
599 try {
600 const tokens = await refreshOAuthToken(currentSession.refreshJwt);
601 const sessionInfo = await api.getSession(
602 unsafeAsAccessToken(tokens.access_token),
603 );
604 const session: Session = {
605 ...sessionInfo,
606 accessJwt: unsafeAsAccessToken(tokens.access_token),
607 refreshJwt: tokens.refresh_token
608 ? unsafeAsRefreshToken(tokens.refresh_token)
609 : currentSession.refreshJwt,
610 };
611 setAuthenticated(session);
612 return session.accessJwt;
613 } catch {
614 return null;
615 }
616 }
617 return null;
618 }
619}
620
621export function isAuthenticated(): boolean {
622 return state.current.kind === "authenticated";
623}
624
625export function isLoading(): boolean {
626 return state.current.kind === "loading";
627}
628
629export function getError(): AuthError | null {
630 return state.current.kind === "error" ? state.current.error : null;
631}
632
633export function getSession(): Session | null {
634 return state.current.kind === "authenticated" ? state.current.session : null;
635}
636
637export function matchAuthState<T>(handlers: {
638 unauthenticated: (accounts: readonly SavedAccount[]) => T;
639 loading: (
640 accounts: readonly SavedAccount[],
641 previousSession: Session | null,
642 ) => T;
643 authenticated: (session: Session, accounts: readonly SavedAccount[]) => T;
644 error: (error: AuthError, accounts: readonly SavedAccount[]) => T;
645}): T {
646 const current = state.current;
647 switch (current.kind) {
648 case "unauthenticated":
649 return handlers.unauthenticated(current.savedAccounts);
650 case "loading":
651 return handlers.loading(current.savedAccounts, current.previousSession);
652 case "authenticated":
653 return handlers.authenticated(current.session, current.savedAccounts);
654 case "error":
655 return handlers.error(current.error, current.savedAccounts);
656 default:
657 return assertNever(current);
658 }
659}
660
661export function _testSetState(newState: {
662 session: Session | null;
663 loading: boolean;
664 error: string | null;
665 savedAccounts?: SavedAccount[];
666}): void {
667 const accounts = newState.savedAccounts ?? [];
668 if (newState.loading) {
669 setState(createLoading(accounts, newState.session));
670 } else if (newState.error) {
671 setState(
672 createError({ type: "unknown", message: newState.error }, accounts),
673 );
674 } else if (newState.session) {
675 setState(createAuthenticated(newState.session, accounts));
676 } else {
677 setState(createUnauthenticated(accounts));
678 }
679}
680
681export function _testResetState(): void {
682 setState(createLoading([]));
683}
684
685export function _testReset(): void {
686 _testResetState();
687 localStorage.removeItem(STORAGE_KEY);
688 localStorage.removeItem(ACCOUNTS_KEY);
689}
690
691export { type Session };