search and/or read your saved and liked bluesky posts
wails
go
svelte
sqlite
desktop
bluesky
1package main
2
3import (
4 "context"
5 "fmt"
6 "net"
7 "net/http"
8 "os/exec"
9 rt "runtime"
10 "strings"
11
12 "github.com/bluesky-social/indigo/atproto/auth/oauth"
13 "github.com/bluesky-social/indigo/atproto/identity"
14 "github.com/bluesky-social/indigo/atproto/syntax"
15)
16
17// AuthService provides authentication functionality via Wails bindings
18type AuthService struct {
19 ctx context.Context
20 app *oauth.ClientApp
21 server *http.Server
22 listener net.Listener
23 codeChan chan string
24 errChan chan error
25 port int
26}
27
28// NewAuthService creates a new AuthService instance
29func NewAuthService() *AuthService {
30 return &AuthService{
31 codeChan: make(chan string, 1),
32 errChan: make(chan error, 1),
33 }
34}
35
36func (s *AuthService) setContext(ctx context.Context) {
37 s.ctx = ctx
38}
39
40// Login initiates OAuth login flow for the given handle
41func (s *AuthService) Login(handle string) error {
42 ctx := context.Background()
43 s.codeChan = make(chan string, 1)
44 s.errChan = make(chan error, 1)
45
46 listener, err := net.Listen("tcp", listenerAddress())
47 if err != nil {
48 return fmt.Errorf("failed to start listener: %w", err)
49 }
50 s.listener = listener
51 s.port = oauthCallbackPort
52
53 store := NewSQLiteOAuthStore()
54 s.app = newOAuthApp(store)
55
56 redirectURL, err := s.app.StartAuthFlow(ctx, handle)
57 if err != nil {
58 closeCallbackServer(nil, s.listener)
59 s.listener = nil
60 return fmt.Errorf("failed to start auth flow: %w", err)
61 }
62
63 s.startCallbackServer()
64 defer s.stopCallbackServer()
65
66 if err := openBrowser(redirectURL); err != nil {
67 return fmt.Errorf("failed to open browser: %w", err)
68 }
69
70 select {
71 case code := <-s.codeChan:
72 return s.exchangeCode(ctx, code)
73 case err := <-s.errChan:
74 return fmt.Errorf("authorization error: %w", err)
75 case <-ctx.Done():
76 return ctx.Err()
77 }
78}
79
80func (s *AuthService) startCallbackServer() {
81 mux := http.NewServeMux()
82 mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
83 query := r.URL.Query()
84 code := query.Get("code")
85 if code == "" {
86 errMsg := query.Get("error")
87 if errMsg == "" {
88 errMsg = "missing authorization code"
89 }
90 errDesc := query.Get("error_description")
91 s.errChan <- fmt.Errorf("authorization failed: %s - %s", errMsg, errDesc)
92 w.WriteHeader(http.StatusBadRequest)
93 fmt.Fprintf(w, "Authorization failed: %s\n", errMsg)
94 return
95 }
96
97 state := query.Get("state")
98 iss := query.Get("iss")
99 s.codeChan <- fmt.Sprintf("%s|%s|%s", code, state, iss)
100 w.WriteHeader(http.StatusOK)
101 fmt.Fprintln(w, "Authorization successful! You can close this window.")
102 })
103
104 s.server = &http.Server{
105 Handler: mux,
106 }
107
108 go func() {
109 if err := s.server.Serve(s.listener); err != nil && err != http.ErrServerClosed {
110 s.errChan <- err
111 }
112 }()
113}
114
115func (s *AuthService) stopCallbackServer() {
116 closeCallbackServer(s.server, s.listener)
117 s.server = nil
118 s.listener = nil
119 s.port = 0
120}
121
122func (s *AuthService) exchangeCode(ctx context.Context, data string) error {
123 parts := strings.SplitN(data, "|", 3)
124 if len(parts) < 2 {
125 return fmt.Errorf("invalid callback data")
126 }
127
128 params := make(map[string][]string)
129 params["code"] = []string{parts[0]}
130 params["state"] = []string{parts[1]}
131 if len(parts) > 2 && parts[2] != "" {
132 params["iss"] = []string{parts[2]}
133 }
134
135 sessData, err := s.app.ProcessCallback(ctx, params)
136 if err != nil {
137 return fmt.Errorf("failed to process callback: %w", err)
138 }
139
140 current, err := GetAuthByDID(sessData.AccountDID.String())
141 if err != nil {
142 return fmt.Errorf("failed to load persisted auth: %w", err)
143 }
144
145 handle := ""
146 if current != nil {
147 handle = current.Handle
148 }
149
150 auth := authFromSessionData(sessData, handle)
151
152 if err := UpsertAuth(auth); err != nil {
153 return fmt.Errorf("failed to persist auth: %w", err)
154 }
155
156 return nil
157}
158
159// Whoami returns the current authenticated user, optionally resolving handle from DID.
160func (s *AuthService) Whoami(force bool) (*Auth, error) {
161 auth, err := GetAuth()
162 if err != nil {
163 return nil, fmt.Errorf("failed to load auth: %w", err)
164 }
165 if auth == nil {
166 return nil, fmt.Errorf("not logged in")
167 }
168
169 if force || strings.HasPrefix(auth.Handle, "did:") {
170 did, err := syntax.ParseDID(auth.DID)
171 if err != nil {
172 return nil, fmt.Errorf("invalid DID in database: %w", err)
173 }
174
175 dir := &identity.BaseDirectory{}
176 ident, err := dir.LookupDID(context.Background(), did)
177 if err != nil {
178 LogWarnf("failed to resolve handle for %s: %v", auth.DID, err)
179 return auth, nil
180 }
181
182 auth.Handle = ident.Handle.String()
183 if err := UpsertAuth(auth); err != nil {
184 return nil, fmt.Errorf("failed to persist resolved handle: %w", err)
185 }
186
187 }
188
189 return auth, nil
190}
191
192// IsAuthenticated checks if there is a valid auth record
193func (s *AuthService) IsAuthenticated() bool {
194 auth, err := GetAuth()
195 if err != nil {
196 return false
197 }
198 return auth != nil
199}
200
201// RefreshSession attempts to refresh the access token if needed
202func (s *AuthService) RefreshSession() error {
203 auth, err := GetAuth()
204 if err != nil {
205 return fmt.Errorf("failed to load auth: %w", err)
206 }
207 if auth == nil {
208 return fmt.Errorf("no session found")
209 }
210
211 if auth.SessionID == "" {
212 return nil // Cannot refresh without session ID
213 }
214
215 store := NewSQLiteOAuthStore()
216 app := newOAuthApp(store)
217
218 did, err := syntax.ParseDID(auth.DID)
219 if err != nil {
220 return fmt.Errorf("invalid DID in database: %w", err)
221 }
222
223 session, err := app.ResumeSession(context.Background(), did, auth.SessionID)
224 if err != nil {
225 return fmt.Errorf("failed to resume session: %w", err)
226 }
227
228 if _, err := session.RefreshTokens(context.Background()); err != nil {
229 return fmt.Errorf("failed to refresh tokens: %w", err)
230 }
231
232 if err := UpsertAuth(authFromSessionData(session.Data, auth.Handle)); err != nil {
233 return fmt.Errorf("failed to persist refreshed session: %w", err)
234 }
235
236 return nil
237}
238
239// Logout revokes the current session when possible and clears local auth state.
240func (s *AuthService) Logout() error {
241 auth, err := GetAuth()
242 if err != nil {
243 return fmt.Errorf("failed to load auth: %w", err)
244 }
245 if auth == nil {
246 return nil
247 }
248
249 if auth.SessionID != "" {
250 store := NewSQLiteOAuthStore()
251 app := newOAuthApp(store)
252
253 did, err := syntax.ParseDID(auth.DID)
254 if err == nil {
255 session, resumeErr := app.ResumeSession(context.Background(), did, auth.SessionID)
256 if resumeErr == nil {
257 if revokeErr := session.RevokeSession(context.Background()); revokeErr != nil {
258 LogWarnf("failed to revoke remote session for %s: %v", auth.DID, revokeErr)
259 }
260 } else {
261 LogWarnf("failed to resume session for logout (%s): %v", auth.DID, resumeErr)
262 }
263 } else {
264 LogWarnf("failed to parse DID for logout (%s): %v", auth.DID, err)
265 }
266 }
267
268 if err := ClearAuth(); err != nil {
269 return fmt.Errorf("failed to clear auth: %w", err)
270 }
271
272 return nil
273}
274
275func openBrowser(url string) error {
276 var cmd string
277 var args []string
278
279 switch rt.GOOS {
280 case "darwin":
281 cmd = "open"
282 args = []string{url}
283 case "windows":
284 cmd = "cmd"
285 args = []string{"/c", "start", url}
286 default:
287 cmd = "xdg-open"
288 args = []string{url}
289 }
290
291 return exec.Command(cmd, args...).Start()
292}