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