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 clearOAuthCallbackParams,
24 handleOAuthCallback,
25 refreshOAuthToken,
26 startOAuthLogin,
27} from "./oauth.ts";
28import { setLocale, type SupportedLocale } from "./i18n.ts";
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 (
174 a,
175 ): a is {
176 did: string;
177 handle: string;
178 accessJwt: string;
179 refreshJwt: string;
180 } =>
181 typeof a === "object" &&
182 a !== null &&
183 typeof a.did === "string" &&
184 typeof a.handle === "string" &&
185 typeof a.accessJwt === "string" &&
186 typeof a.refreshJwt === "string",
187 )
188 .map((a) => ({
189 did: unsafeAsDid(a.did),
190 handle: unsafeAsHandle(a.handle),
191 accessJwt: unsafeAsAccessToken(a.accessJwt),
192 refreshJwt: unsafeAsRefreshToken(a.refreshJwt),
193 }));
194 return ok(accounts);
195 } catch (e) {
196 return err(e instanceof Error ? e : new Error("Failed to parse accounts"));
197 }
198}
199
200function loadSessionFromStorage(): StoredSession | null {
201 const stored = localStorage.getItem(STORAGE_KEY);
202 if (!stored) return null;
203 const result = parseStoredSession(stored);
204 return isOk(result) ? result.value : null;
205}
206
207function loadSavedAccountsFromStorage(): readonly SavedAccount[] {
208 const stored = localStorage.getItem(ACCOUNTS_KEY);
209 if (!stored) return [];
210 const result = parseStoredAccounts(stored);
211 return isOk(result) ? result.value : [];
212}
213
214function persistSession(session: Session | null): void {
215 if (session) {
216 localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
217 } else {
218 localStorage.removeItem(STORAGE_KEY);
219 }
220}
221
222function persistSavedAccounts(accounts: readonly SavedAccount[]): void {
223 localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts));
224}
225
226function updateSavedAccounts(
227 accounts: readonly SavedAccount[],
228 session: Session,
229): readonly SavedAccount[] {
230 const newAccount = sessionToSavedAccount(session);
231 const filtered = accounts.filter((a) => a.did !== newAccount.did);
232 return [...filtered, newAccount];
233}
234
235function removeSavedAccountByDid(
236 accounts: readonly SavedAccount[],
237 did: Did,
238): readonly SavedAccount[] {
239 return accounts.filter((a) => a.did !== did);
240}
241
242function findSavedAccount(
243 accounts: readonly SavedAccount[],
244 did: Did,
245): SavedAccount | undefined {
246 return accounts.find((a) => a.did === did);
247}
248
249function getSavedAccounts(): readonly SavedAccount[] {
250 return state.current.savedAccounts;
251}
252
253function setState(newState: AuthState): void {
254 state.current = newState;
255}
256
257function setAuthenticated(session: Session): void {
258 const accounts = updateSavedAccounts(getSavedAccounts(), session);
259 persistSession(session);
260 persistSavedAccounts(accounts);
261 setState(createAuthenticated(session, accounts));
262}
263
264function setUnauthenticated(): void {
265 persistSession(null);
266 setState(createUnauthenticated(getSavedAccounts()));
267}
268
269function setError(error: AuthError): void {
270 setState(createError(error, getSavedAccounts()));
271}
272
273function setLoading(previousSession: Session | null = null): void {
274 setState(createLoading(getSavedAccounts(), previousSession));
275}
276
277async function tryRefreshToken(): Promise<string | null> {
278 if (state.current.kind !== "authenticated") return null;
279 const currentSession = state.current.session;
280 try {
281 const tokens = await refreshOAuthToken(currentSession.refreshJwt);
282 const sessionInfo = await api.getSession(
283 unsafeAsAccessToken(tokens.access_token),
284 );
285 const session: Session = {
286 ...sessionInfo,
287 accessJwt: unsafeAsAccessToken(tokens.access_token),
288 refreshJwt: tokens.refresh_token
289 ? unsafeAsRefreshToken(tokens.refresh_token)
290 : currentSession.refreshJwt,
291 };
292 setAuthenticated(session);
293 return session.accessJwt;
294 } catch {
295 return null;
296 }
297}
298
299import { setTokenRefreshCallback } from "./api.ts";
300
301export async function initAuth(): Promise<{ oauthLoginCompleted: boolean }> {
302 setTokenRefreshCallback(tryRefreshToken);
303 const savedAccounts = loadSavedAccountsFromStorage();
304 setState(createLoading(savedAccounts));
305
306 const oauthCallback = checkForOAuthCallback();
307 if (oauthCallback) {
308 clearOAuthCallbackParams();
309 try {
310 const tokens = await handleOAuthCallback(
311 oauthCallback.code,
312 oauthCallback.state,
313 );
314 const sessionInfo = await api.getSession(
315 unsafeAsAccessToken(tokens.access_token),
316 );
317 const session: Session = {
318 ...sessionInfo,
319 accessJwt: unsafeAsAccessToken(tokens.access_token),
320 refreshJwt: unsafeAsRefreshToken(tokens.refresh_token || ""),
321 };
322 setAuthenticated(session);
323 applyLocaleFromSession(session);
324 return { oauthLoginCompleted: true };
325 } catch (e) {
326 setError({
327 type: "oauth",
328 message: e instanceof Error ? e.message : "OAuth login failed",
329 });
330 return { oauthLoginCompleted: false };
331 }
332 }
333
334 const stored = loadSessionFromStorage();
335 if (stored) {
336 try {
337 const sessionInfo = await api.getSession(
338 unsafeAsAccessToken(stored.accessJwt),
339 );
340 const session: Session = {
341 ...sessionInfo,
342 accessJwt: unsafeAsAccessToken(stored.accessJwt),
343 refreshJwt: unsafeAsRefreshToken(stored.refreshJwt),
344 };
345 setAuthenticated(session);
346 applyLocaleFromSession(session);
347 } catch (e) {
348 if (e instanceof ApiError && e.status === 401) {
349 try {
350 const tokens = await refreshOAuthToken(stored.refreshJwt);
351 const sessionInfo = await api.getSession(
352 unsafeAsAccessToken(tokens.access_token),
353 );
354 const session: Session = {
355 ...sessionInfo,
356 accessJwt: unsafeAsAccessToken(tokens.access_token),
357 refreshJwt: tokens.refresh_token
358 ? unsafeAsRefreshToken(tokens.refresh_token)
359 : unsafeAsRefreshToken(stored.refreshJwt),
360 };
361 setAuthenticated(session);
362 applyLocaleFromSession(session);
363 } catch (refreshError) {
364 console.error("Token refresh failed during init:", refreshError);
365 setUnauthenticated();
366 }
367 } else {
368 console.error("Non-401 error during getSession:", e);
369 setUnauthenticated();
370 }
371 }
372 } else {
373 setState(createUnauthenticated(savedAccounts));
374 }
375
376 return { oauthLoginCompleted: false };
377}
378
379export async function login(
380 identifier: string,
381 password: string,
382): Promise<Result<Session, AuthError>> {
383 const currentState = state.current;
384 const previousSession = currentState.kind === "authenticated"
385 ? currentState.session
386 : null;
387 setLoading(previousSession);
388
389 const result = await typedApi.createSession(identifier, password);
390 if (isErr(result)) {
391 const error = toAuthError(result.error);
392 setError(error);
393 return err(error);
394 }
395
396 setAuthenticated(result.value);
397 return ok(result.value);
398}
399
400export async function loginWithOAuth(): Promise<Result<void, AuthError>> {
401 setLoading();
402 try {
403 await startOAuthLogin();
404 return ok(undefined);
405 } catch (e) {
406 const error = toAuthError(e);
407 setError(error);
408 return err(error);
409 }
410}
411
412export async function register(
413 params: CreateAccountParams,
414): Promise<Result<CreateAccountResult, AuthError>> {
415 try {
416 const result = await api.createAccount(params);
417 return ok(result);
418 } catch (e) {
419 return err(toAuthError(e));
420 }
421}
422
423export async function confirmSignup(
424 did: Did,
425 verificationCode: string,
426): Promise<Result<Session, AuthError>> {
427 setLoading();
428 try {
429 const result = await api.confirmSignup(did, verificationCode);
430 setAuthenticated(result);
431 return ok(result);
432 } catch (e) {
433 const error = toAuthError(e);
434 setError(error);
435 return err(error);
436 }
437}
438
439export async function resendVerification(
440 did: Did,
441): Promise<Result<void, AuthError>> {
442 try {
443 await api.resendVerification(did);
444 return ok(undefined);
445 } catch (e) {
446 return err(toAuthError(e));
447 }
448}
449
450export function setSession(session: {
451 did: string;
452 handle: string;
453 accessJwt: string;
454 refreshJwt: string;
455}): void {
456 const newSession: Session = {
457 did: unsafeAsDid(session.did),
458 handle: unsafeAsHandle(session.handle),
459 accessJwt: unsafeAsAccessToken(session.accessJwt),
460 refreshJwt: unsafeAsRefreshToken(session.refreshJwt),
461 };
462 setAuthenticated(newSession);
463}
464
465export async function logout(): Promise<Result<void, AuthError>> {
466 if (state.current.kind === "authenticated") {
467 const { session } = state.current;
468 const did = unsafeAsDid(session.did);
469 try {
470 await fetch("/oauth/revoke", {
471 method: "POST",
472 headers: { "Content-Type": "application/x-www-form-urlencoded" },
473 body: new URLSearchParams({ token: session.refreshJwt }),
474 });
475 } catch {
476 // Ignore revocation errors
477 }
478 const accounts = removeSavedAccountByDid(getSavedAccounts(), did);
479 persistSavedAccounts(accounts);
480 persistSession(null);
481 setState(createUnauthenticated(accounts));
482 } else {
483 setUnauthenticated();
484 }
485 return ok(undefined);
486}
487
488export async function switchAccount(
489 did: Did,
490): Promise<Result<Session, AuthError>> {
491 const account = findSavedAccount(getSavedAccounts(), did);
492 if (!account) {
493 return err({ type: "validation", message: "Account not found" });
494 }
495
496 setLoading();
497
498 try {
499 const sessionInfo = await api.getSession(account.accessJwt);
500 const session: Session = {
501 ...sessionInfo,
502 accessJwt: account.accessJwt,
503 refreshJwt: account.refreshJwt,
504 };
505 setAuthenticated(session);
506 return ok(session);
507 } catch (e) {
508 if (e instanceof ApiError && e.status === 401) {
509 try {
510 const tokens = await refreshOAuthToken(account.refreshJwt);
511 const sessionInfo = await api.getSession(
512 unsafeAsAccessToken(tokens.access_token),
513 );
514 const session: Session = {
515 ...sessionInfo,
516 accessJwt: unsafeAsAccessToken(tokens.access_token),
517 refreshJwt: tokens.refresh_token
518 ? unsafeAsRefreshToken(tokens.refresh_token)
519 : account.refreshJwt,
520 };
521 setAuthenticated(session);
522 return ok(session);
523 } catch {
524 const accounts = removeSavedAccountByDid(getSavedAccounts(), did);
525 persistSavedAccounts(accounts);
526 const error: AuthError = {
527 type: "unauthorized",
528 message: "Session expired. Please log in again.",
529 };
530 setState(createError(error, accounts));
531 return err(error);
532 }
533 }
534 const error = toAuthError(e);
535 setError(error);
536 return err(error);
537 }
538}
539
540export function forgetAccount(did: Did): void {
541 const accounts = removeSavedAccountByDid(getSavedAccounts(), did);
542 persistSavedAccounts(accounts);
543 setState({
544 ...state.current,
545 savedAccounts: accounts,
546 } as AuthState);
547}
548
549export function getAuthState(): AuthState {
550 return state.current;
551}
552
553export async function refreshSession(): Promise<Result<Session, AuthError>> {
554 if (state.current.kind !== "authenticated") {
555 return err({ type: "unauthorized", message: "Not authenticated" });
556 }
557 const currentSession = state.current.session;
558 try {
559 const sessionInfo = await api.getSession(currentSession.accessJwt);
560 const session: Session = {
561 ...sessionInfo,
562 accessJwt: currentSession.accessJwt,
563 refreshJwt: currentSession.refreshJwt,
564 };
565 setAuthenticated(session);
566 return ok(session);
567 } catch (e) {
568 console.error("Failed to refresh session:", e);
569 return err(toAuthError(e));
570 }
571}
572
573export function getToken(): AccessToken | null {
574 if (state.current.kind === "authenticated") {
575 return state.current.session.accessJwt;
576 }
577 return null;
578}
579
580export async function getValidToken(): Promise<AccessToken | null> {
581 if (state.current.kind !== "authenticated") return null;
582 const currentSession = state.current.session;
583 try {
584 await api.getSession(currentSession.accessJwt);
585 return currentSession.accessJwt;
586 } catch (e) {
587 if (e instanceof ApiError && e.status === 401) {
588 try {
589 const tokens = await refreshOAuthToken(currentSession.refreshJwt);
590 const sessionInfo = await api.getSession(
591 unsafeAsAccessToken(tokens.access_token),
592 );
593 const session: Session = {
594 ...sessionInfo,
595 accessJwt: unsafeAsAccessToken(tokens.access_token),
596 refreshJwt: tokens.refresh_token
597 ? unsafeAsRefreshToken(tokens.refresh_token)
598 : currentSession.refreshJwt,
599 };
600 setAuthenticated(session);
601 return session.accessJwt;
602 } catch {
603 return null;
604 }
605 }
606 return null;
607 }
608}
609
610export function isAuthenticated(): boolean {
611 return state.current.kind === "authenticated";
612}
613
614export function isLoading(): boolean {
615 return state.current.kind === "loading";
616}
617
618export function getError(): AuthError | null {
619 return state.current.kind === "error" ? state.current.error : null;
620}
621
622export function getSession(): Session | null {
623 return state.current.kind === "authenticated" ? state.current.session : null;
624}
625
626export function matchAuthState<T>(handlers: {
627 unauthenticated: (accounts: readonly SavedAccount[]) => T;
628 loading: (
629 accounts: readonly SavedAccount[],
630 previousSession: Session | null,
631 ) => T;
632 authenticated: (session: Session, accounts: readonly SavedAccount[]) => T;
633 error: (error: AuthError, accounts: readonly SavedAccount[]) => T;
634}): T {
635 const current = state.current;
636 switch (current.kind) {
637 case "unauthenticated":
638 return handlers.unauthenticated(current.savedAccounts);
639 case "loading":
640 return handlers.loading(current.savedAccounts, current.previousSession);
641 case "authenticated":
642 return handlers.authenticated(current.session, current.savedAccounts);
643 case "error":
644 return handlers.error(current.error, current.savedAccounts);
645 default:
646 return assertNever(current);
647 }
648}
649
650export function _testSetState(newState: {
651 session: Session | null;
652 loading: boolean;
653 error: string | null;
654 savedAccounts?: SavedAccount[];
655}): void {
656 const accounts = newState.savedAccounts ?? [];
657 if (newState.loading) {
658 setState(createLoading(accounts, newState.session));
659 } else if (newState.error) {
660 setState(
661 createError({ type: "unknown", message: newState.error }, accounts),
662 );
663 } else if (newState.session) {
664 setState(createAuthenticated(newState.session, accounts));
665 } else {
666 setState(createUnauthenticated(accounts));
667 }
668}
669
670export function _testResetState(): void {
671 setState(createLoading([]));
672}
673
674export function _testReset(): void {
675 _testResetState();
676 localStorage.removeItem(STORAGE_KEY);
677 localStorage.removeItem(ACCOUNTS_KEY);
678}
679
680export { type Session };