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