search and/or read your saved and liked bluesky posts
wails go svelte sqlite desktop bluesky
at main 292 lines 7.1 kB view raw
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}