an appview-less Bluesky client using Constellation and PDS Queries
reddwarf.app
frontend
spa
bluesky
reddwarf
microcosm
1import { Agent, AtpAgent, type AtpSessionData } from "@atproto/api";
2import {
3 type OAuthSession,
4 TokenInvalidError,
5 TokenRefreshError,
6 TokenRevokedError,
7} from "@atproto/oauth-client-browser";
8import { useAtom } from "jotai";
9import React, {
10 createContext,
11 use,
12 useCallback,
13 useEffect,
14 useState,
15} from "react";
16
17import { quickAuthAtom } from "~/utils/atoms";
18
19import { oauthClient } from "../utils/oauthClient";
20
21type AuthStatus = "loading" | "signedIn" | "signedOut";
22type AuthMethod = "password" | "oauth" | null;
23
24interface AuthContextValue {
25 agent: Agent | null;
26 status: AuthStatus;
27 authMethod: AuthMethod;
28 loginWithPassword: (
29 user: string,
30 password: string,
31 service?: string,
32 ) => Promise<void>;
33 loginWithOAuth: (handleOrPdsUrl: string) => Promise<void>;
34 logout: () => Promise<void>;
35}
36
37const AuthContext = createContext<AuthContextValue>({} as AuthContextValue);
38
39export const UnifiedAuthProvider = ({
40 children,
41}: {
42 children: React.ReactNode;
43}) => {
44 const [agent, setAgent] = useState<Agent | null>(null);
45 const [status, setStatus] = useState<AuthStatus>("loading");
46 const [authMethod, setAuthMethod] = useState<AuthMethod>(null);
47 const [oauthSession, setOauthSession] = useState<OAuthSession | null>(null);
48 const [quickAuth, setQuickAuth] = useAtom(quickAuthAtom);
49
50 const initialize = useCallback(async () => {
51 try {
52 const oauthResult = await oauthClient.init();
53 if (oauthResult) {
54 // /*mass comment*/ console.log("OAuth session restored.");
55 const apiAgent = new Agent(oauthResult.session);
56 setAgent(apiAgent);
57 setOauthSession(oauthResult.session);
58 setAuthMethod("oauth");
59 setStatus("signedIn");
60 setQuickAuth(apiAgent?.did || null);
61 return;
62 }
63 } catch (e) {
64 console.error("OAuth init failed, checking password session.", e);
65 if (!quickAuth) {
66 // quickAuth restoration. if last used method is oauth we immediately call for oauth redo
67 // (and set a persistent atom somewhere to not retry again if it failed)
68 }
69 }
70
71 try {
72 const service = localStorage.getItem("service");
73 const sessionString = localStorage.getItem("sess");
74
75 if (service && sessionString) {
76 // /*mass comment*/ console.log("Resuming password-based session using AtpAgent...");
77 const apiAgent = new AtpAgent({ service });
78 const session: AtpSessionData = JSON.parse(sessionString);
79 await apiAgent.resumeSession(session);
80
81 // /*mass comment*/ console.log("Password-based session resumed successfully.");
82 setAgent(apiAgent);
83 setAuthMethod("password");
84 setStatus("signedIn");
85 setQuickAuth(apiAgent?.did || null);
86 return;
87 }
88 } catch (e) {
89 console.error("Failed to resume password-based session.", e);
90 localStorage.removeItem("sess");
91 localStorage.removeItem("service");
92 }
93
94 // /*mass comment*/ console.log("No active session found.");
95 setStatus("signedOut");
96 setAgent(null);
97 setAuthMethod(null);
98 // do we want to null it here?
99 setQuickAuth(null);
100 }, [quickAuth, setQuickAuth]);
101
102 useEffect(() => {
103 const handleOAuthSessionDeleted = (
104 event: CustomEvent<{ sub: string; cause: TokenRefreshError | TokenRevokedError | TokenInvalidError }>,
105 ) => {
106 console.error(`OAuth Session for ${event.detail.sub} was deleted.`, event.detail.cause);
107 setAgent(null);
108 setOauthSession(null);
109 setAuthMethod(null);
110 setStatus("signedOut");
111 setQuickAuth(null);
112 };
113
114 oauthClient.addEventListener("deleted", handleOAuthSessionDeleted as EventListener);
115 initialize();
116
117 return () => {
118 oauthClient.removeEventListener("deleted", handleOAuthSessionDeleted as EventListener);
119 };
120 }, [initialize, setQuickAuth]);
121
122 const loginWithPassword = async (
123 user: string,
124 password: string,
125 service: string = "https://bsky.social",
126 ) => {
127 if (status !== "signedOut") return;
128 setStatus("loading");
129 try {
130 let sessionData: AtpSessionData | undefined;
131 const apiAgent = new AtpAgent({
132 service,
133 persistSession: (_evt, sess) => {
134 sessionData = sess;
135 },
136 });
137 await apiAgent.login({ identifier: user, password });
138
139 if (sessionData) {
140 localStorage.setItem("service", service);
141 localStorage.setItem("sess", JSON.stringify(sessionData));
142 setAgent(apiAgent);
143 setAuthMethod("password");
144 setStatus("signedIn");
145 setQuickAuth(apiAgent?.did || null);
146 // /*mass comment*/ console.log("Successfully logged in with password.");
147 } else {
148 throw new Error("Session data not persisted after login.");
149 }
150 } catch (e) {
151 console.error("Password login failed:", e);
152 setStatus("signedOut");
153 setQuickAuth(null);
154 throw e;
155 }
156 };
157
158 const loginWithOAuth = useCallback(async (handleOrPdsUrl: string) => {
159 if (status !== "signedOut") return;
160 try {
161 sessionStorage.setItem("postLoginRedirect", window.location.pathname + window.location.search);
162 await oauthClient.signIn(handleOrPdsUrl);
163 } catch (err) {
164 console.error("OAuth sign-in aborted or failed:", err);
165 }
166 }, [status]);
167
168 const logout = useCallback(async () => {
169 if (status !== "signedIn" || !agent) return;
170 setStatus("loading");
171
172 try {
173 if (authMethod === "oauth" && oauthSession) {
174 await oauthClient.revoke(oauthSession.sub);
175 // /*mass comment*/ console.log("OAuth session revoked.");
176 } else if (authMethod === "password") {
177 localStorage.removeItem("service");
178 localStorage.removeItem("sess");
179 await (agent as AtpAgent).com.atproto.server.deleteSession();
180 // /*mass comment*/ console.log("Password-based session deleted.");
181 }
182 } catch (e) {
183 console.error("Logout failed:", e);
184 } finally {
185 setAgent(null);
186 setAuthMethod(null);
187 setOauthSession(null);
188 setStatus("signedOut");
189 setQuickAuth(null);
190 }
191 }, [status, agent, authMethod, oauthSession, setQuickAuth]);
192
193 return (
194 <AuthContext
195 value={{
196 agent,
197 status,
198 authMethod,
199 loginWithPassword,
200 loginWithOAuth,
201 logout,
202 }}
203 >
204 {children}
205 </AuthContext>
206 );
207};
208
209export const useAuth = () => use(AuthContext);