search and/or read your saved and liked bluesky posts
wails go svelte sqlite desktop bluesky
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: SQLite-backed OAuth session storage

+441 -82
+1 -1
TODO.md
··· 66 66 - [ ] Error handling: toast/notification for network failures, auth expiry 67 67 - [ ] Keyboard shortcuts: `Cmd+K` focus search, `Cmd+R` refresh, `Cmd+L` toggle log viewer 68 68 - [ ] Window title and app icon (`build/appicon.png`) 69 - - [ ] Production build verification (`wails3 build` → macOS `.app` bundle) 69 + - [ ] Production build verification (`wails build` → macOS `.app` bundle) 70 70 - [ ] README with build instructions, screenshots, and usage
+34 -36
auth_service.go
··· 8 8 "os/exec" 9 9 rt "runtime" 10 10 "strings" 11 - "time" 12 11 13 12 "github.com/bluesky-social/indigo/atproto/auth/oauth" 14 13 "github.com/bluesky-social/indigo/atproto/identity" ··· 36 35 // Login initiates OAuth login flow for the given handle 37 36 func (s *AuthService) Login(handle string) error { 38 37 ctx := context.Background() 38 + s.codeChan = make(chan string, 1) 39 + s.errChan = make(chan error, 1) 39 40 40 - listener, err := net.Listen("tcp", "127.0.0.1:0") 41 + listener, err := net.Listen("tcp", listenerAddress()) 41 42 if err != nil { 42 43 return fmt.Errorf("failed to start listener: %w", err) 43 44 } 44 45 s.listener = listener 45 - s.port = listener.Addr().(*net.TCPAddr).Port 46 + s.port = oauthCallbackPort 46 47 47 - redirectURI := fmt.Sprintf("http://127.0.0.1:%d/callback", s.port) 48 - scopes := []string{"atproto", "transition:generic"} 49 - 50 - config := oauth.NewLocalhostConfig(redirectURI, scopes) 51 - store := oauth.NewMemStore() 52 - s.app = oauth.NewClientApp(&config, store) 48 + store := NewSQLiteOAuthStore() 49 + s.app = newOAuthApp(store) 53 50 54 51 redirectURL, err := s.app.StartAuthFlow(ctx, handle) 55 52 if err != nil { 53 + closeCallbackServer(nil, s.listener) 54 + s.listener = nil 56 55 return fmt.Errorf("failed to start auth flow: %w", err) 57 56 } 58 57 59 58 s.startCallbackServer() 59 + defer s.stopCallbackServer() 60 60 61 61 if err := openBrowser(redirectURL); err != nil { 62 62 return fmt.Errorf("failed to open browser: %w", err) ··· 107 107 }() 108 108 } 109 109 110 + func (s *AuthService) stopCallbackServer() { 111 + closeCallbackServer(s.server, s.listener) 112 + s.server = nil 113 + s.listener = nil 114 + s.port = 0 115 + } 116 + 110 117 func (s *AuthService) exchangeCode(ctx context.Context, data string) error { 111 118 parts := strings.SplitN(data, "|", 3) 112 119 if len(parts) < 2 { ··· 125 132 return fmt.Errorf("failed to process callback: %w", err) 126 133 } 127 134 128 - auth := &Auth{ 129 - DID: sessData.AccountDID.String(), 130 - Handle: sessData.AccountDID.String(), 131 - AccessJWT: sessData.AccessToken, 132 - RefreshJWT: sessData.RefreshToken, 133 - PDSURL: sessData.HostURL, 134 - SessionID: sessData.SessionID, 135 - AuthServerURL: sessData.AuthServerURL, 136 - AuthServerTokenEndpoint: sessData.AuthServerTokenEndpoint, 137 - AuthServerRevocationEndpoint: sessData.AuthServerRevocationEndpoint, 138 - DPoPAuthNonce: sessData.DPoPAuthServerNonce, 139 - DPoPHostNonce: sessData.DPoPHostNonce, 140 - DPoPPrivateKey: sessData.DPoPPrivateKeyMultibase, 141 - UpdatedAt: time.Now(), 135 + current, err := GetAuthByDID(sessData.AccountDID.String()) 136 + if err != nil { 137 + return fmt.Errorf("failed to load persisted auth: %w", err) 142 138 } 143 139 140 + handle := "" 141 + if current != nil { 142 + handle = current.Handle 143 + } 144 + 145 + auth := authFromSessionData(sessData, handle) 146 + 144 147 if err := UpsertAuth(auth); err != nil { 145 148 return fmt.Errorf("failed to persist auth: %w", err) 146 149 } ··· 173 176 } 174 177 175 178 auth.Handle = ident.Handle.String() 179 + if err := UpsertAuth(auth); err != nil { 180 + return nil, fmt.Errorf("failed to persist resolved handle: %w", err) 181 + } 176 182 177 183 } 178 184 ··· 202 208 return nil // Cannot refresh without session ID 203 209 } 204 210 205 - redirectURI := "http://127.0.0.1/callback" 206 - scopes := []string{"atproto", "transition:generic"} 207 - config := oauth.NewLocalhostConfig(redirectURI, scopes) 208 - store := oauth.NewMemStore() 209 - app := oauth.NewClientApp(&config, store) 211 + store := NewSQLiteOAuthStore() 212 + app := newOAuthApp(store) 210 213 211 214 did, err := syntax.ParseDID(auth.DID) 212 215 if err != nil { ··· 218 221 return fmt.Errorf("failed to resume session: %w", err) 219 222 } 220 223 221 - newAccessToken, err := session.RefreshTokens(context.Background()) 222 - if err != nil { 224 + if _, err := session.RefreshTokens(context.Background()); err != nil { 223 225 return fmt.Errorf("failed to refresh tokens: %w", err) 224 226 } 225 227 226 - if newAccessToken != "" { 227 - auth.AccessJWT = newAccessToken 228 - auth.UpdatedAt = time.Now() 229 - if err := UpsertAuth(auth); err != nil { 230 - return fmt.Errorf("failed to update refreshed tokens: %w", err) 231 - } 228 + if err := UpsertAuth(authFromSessionData(session.Data, auth.Handle)); err != nil { 229 + return fmt.Errorf("failed to persist refreshed session: %w", err) 232 230 } 233 231 234 232 return nil
+98 -12
database.go
··· 6 6 "fmt" 7 7 "os" 8 8 "path/filepath" 9 + "strings" 9 10 "time" 10 11 11 12 _ "modernc.org/sqlite" ··· 193 194 query := `SELECT did, handle, access_jwt, refresh_jwt, pds_url, session_id, 194 195 auth_server_url, auth_server_token_endpoint, auth_server_revocation_endpoint, 195 196 dpop_auth_nonce, dpop_host_nonce, dpop_private_key, updated_at 196 - FROM auth LIMIT 1` 197 + FROM auth 198 + ORDER BY updated_at DESC 199 + LIMIT 1` 200 + 201 + auth, err := getAuthByQuery(query) 202 + 203 + if err == sql.ErrNoRows { 204 + fmt.Println("no auth record found in database") 205 + return nil, nil 206 + } 207 + if err != nil { 208 + fmt.Printf("failed to load auth: %v\n", err) 209 + return nil, err 210 + } 211 + 212 + fmt.Printf("auth loaded successfully: %s (%s)\n", auth.DID, auth.Handle) 213 + return auth, nil 214 + } 197 215 216 + // GetAuthByDID loads auth for a specific DID. 217 + func GetAuthByDID(did string) (*Auth, error) { 218 + query := `SELECT did, handle, access_jwt, refresh_jwt, pds_url, session_id, 219 + auth_server_url, auth_server_token_endpoint, auth_server_revocation_endpoint, 220 + dpop_auth_nonce, dpop_host_nonce, dpop_private_key, updated_at 221 + FROM auth 222 + WHERE did = ? 223 + LIMIT 1` 224 + 225 + auth, err := getAuthByQuery(query, did) 226 + if err == sql.ErrNoRows { 227 + return nil, nil 228 + } 229 + if err != nil { 230 + return nil, err 231 + } 232 + return auth, nil 233 + } 234 + 235 + func getAuthByQuery(query string, args ...any) (*Auth, error) { 198 236 var auth Auth 199 237 var updatedAt string 200 238 201 239 var sessionID, authServerURL, authServerTokenEndpoint, authServerRevocationEndpoint, dpopAuthNonce, dpopHostNonce, dpopPrivateKey sql.NullString 202 240 203 - err := db.QueryRow(query).Scan( 241 + err := db.QueryRow(query, args...).Scan( 204 242 &auth.DID, 205 243 &auth.Handle, 206 244 &auth.AccessJWT, ··· 215 253 &dpopPrivateKey, 216 254 &updatedAt, 217 255 ) 256 + if err != nil { 257 + return nil, err 258 + } 218 259 219 260 if sessionID.Valid { 220 261 auth.SessionID = sessionID.String ··· 238 279 auth.DPoPPrivateKey = dpopPrivateKey.String 239 280 } 240 281 241 - if err == sql.ErrNoRows { 242 - fmt.Println("no auth record found in database") 243 - return nil, nil 244 - } 245 - if err != nil { 246 - fmt.Printf("failed to load auth: %v\n", err) 247 - return nil, err 248 - } 249 - 250 282 auth.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) 251 - fmt.Printf("auth loaded successfully: %s (%s)\n", auth.DID, auth.Handle) 252 283 return &auth, nil 253 284 } 254 285 255 286 // SearchPosts searches posts using FTS5 256 287 func SearchPosts(query string, source string) ([]SearchResult, error) { 288 + query = strings.TrimSpace(query) 289 + if query == "*" { 290 + query = "" 291 + } 292 + 257 293 fmt.Printf("searching posts: query=%s, source=%s\n", query, source) 294 + 295 + if query == "" { 296 + return listRecentPosts(source) 297 + } 258 298 259 299 sqlQuery := ` 260 300 SELECT p.uri, p.cid, p.author_did, p.author_handle, p.text, p.created_at, ··· 304 344 } 305 345 306 346 fmt.Printf("search completed: %d results\n", len(results)) 347 + return results, rows.Err() 348 + } 349 + 350 + func listRecentPosts(source string) ([]SearchResult, error) { 351 + rows, err := db.Query(` 352 + SELECT uri, cid, author_did, author_handle, text, created_at, 353 + like_count, repost_count, reply_count, source, indexed_at 354 + FROM posts 355 + WHERE (? = '' OR source = ?) 356 + ORDER BY created_at DESC 357 + LIMIT 25 358 + `, source, source) 359 + if err != nil { 360 + fmt.Printf("failed to list recent posts: %v\n", err) 361 + return nil, err 362 + } 363 + defer rows.Close() 364 + 365 + var results []SearchResult 366 + for rows.Next() { 367 + var r SearchResult 368 + var createdAt, indexedAt string 369 + 370 + err := rows.Scan( 371 + &r.URI, 372 + &r.CID, 373 + &r.AuthorDID, 374 + &r.AuthorHandle, 375 + &r.Text, 376 + &createdAt, 377 + &r.LikeCount, 378 + &r.RepostCount, 379 + &r.ReplyCount, 380 + &r.Source, 381 + &indexedAt, 382 + ) 383 + if err != nil { 384 + return nil, err 385 + } 386 + 387 + r.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) 388 + r.IndexedAt, _ = time.Parse("2006-01-02 15:04:05", indexedAt) 389 + results = append(results, r) 390 + } 391 + 392 + fmt.Printf("browse completed: %d results\n", len(results)) 307 393 return results, rows.Err() 308 394 } 309 395
+139
database_test.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "path/filepath" 6 + "testing" 7 + "time" 8 + 9 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + ) 12 + 13 + func openTestDB(t *testing.T) { 14 + t.Helper() 15 + 16 + dbPath := filepath.Join(t.TempDir(), "test.db") 17 + if err := Open(dbPath); err != nil { 18 + t.Fatalf("Open() error = %v", err) 19 + } 20 + 21 + t.Cleanup(func() { 22 + if err := Close(); err != nil { 23 + t.Fatalf("Close() error = %v", err) 24 + } 25 + }) 26 + } 27 + 28 + func TestSearchPostsBrowseMode(t *testing.T) { 29 + openTestDB(t) 30 + 31 + posts := []*Post{ 32 + { 33 + URI: "at://did:plc:test/app.bsky.feed.post/1", 34 + CID: "cid-1", 35 + AuthorDID: "did:plc:test", 36 + AuthorHandle: "alice.test", 37 + Text: "older saved post", 38 + CreatedAt: time.Date(2026, 3, 14, 12, 0, 0, 0, time.UTC), 39 + Source: "saved", 40 + }, 41 + { 42 + URI: "at://did:plc:test/app.bsky.feed.post/2", 43 + CID: "cid-2", 44 + AuthorDID: "did:plc:test", 45 + AuthorHandle: "alice.test", 46 + Text: "newer liked post", 47 + CreatedAt: time.Date(2026, 3, 15, 12, 0, 0, 0, time.UTC), 48 + Source: "liked", 49 + }, 50 + } 51 + 52 + for _, post := range posts { 53 + if err := InsertPost(post); err != nil { 54 + t.Fatalf("InsertPost() error = %v", err) 55 + } 56 + } 57 + 58 + results, err := SearchPosts("", "") 59 + if err != nil { 60 + t.Fatalf("SearchPosts(empty) error = %v", err) 61 + } 62 + if len(results) != 2 { 63 + t.Fatalf("SearchPosts(empty) len = %d, want 2", len(results)) 64 + } 65 + if results[0].URI != posts[1].URI { 66 + t.Fatalf("SearchPosts(empty) first URI = %q, want %q", results[0].URI, posts[1].URI) 67 + } 68 + 69 + starResults, err := SearchPosts("*", "saved") 70 + if err != nil { 71 + t.Fatalf("SearchPosts(*) error = %v", err) 72 + } 73 + if len(starResults) != 1 { 74 + t.Fatalf("SearchPosts(*) len = %d, want 1", len(starResults)) 75 + } 76 + if starResults[0].Source != "saved" { 77 + t.Fatalf("SearchPosts(*) source = %q, want %q", starResults[0].Source, "saved") 78 + } 79 + } 80 + 81 + func TestSQLiteOAuthStorePersistsSession(t *testing.T) { 82 + openTestDB(t) 83 + 84 + store := NewSQLiteOAuthStore() 85 + did, err := syntax.ParseDID("did:plc:xg2vq45muivyy3xwatcehspu") 86 + if err != nil { 87 + t.Fatalf("ParseDID() error = %v", err) 88 + } 89 + 90 + session := oauth.ClientSessionData{ 91 + AccountDID: did, 92 + SessionID: "session-123", 93 + HostURL: "https://bsky.social", 94 + AuthServerURL: "https://auth.example.com", 95 + AuthServerTokenEndpoint: "https://auth.example.com/token", 96 + AuthServerRevocationEndpoint: "https://auth.example.com/revoke", 97 + Scopes: append([]string(nil), oauthScopes...), 98 + AccessToken: "access-1", 99 + RefreshToken: "refresh-1", 100 + DPoPAuthServerNonce: "auth-nonce", 101 + DPoPHostNonce: "host-nonce", 102 + DPoPPrivateKeyMultibase: "private-key", 103 + } 104 + 105 + if err := store.SaveSession(context.Background(), session); err != nil { 106 + t.Fatalf("SaveSession() error = %v", err) 107 + } 108 + 109 + auth, err := GetAuthByDID(did.String()) 110 + if err != nil { 111 + t.Fatalf("GetAuthByDID() error = %v", err) 112 + } 113 + if auth == nil { 114 + t.Fatal("GetAuthByDID() = nil, want auth") 115 + } 116 + if auth.RefreshJWT != session.RefreshToken { 117 + t.Fatalf("RefreshJWT = %q, want %q", auth.RefreshJWT, session.RefreshToken) 118 + } 119 + if auth.DPoPHostNonce != session.DPoPHostNonce { 120 + t.Fatalf("DPoPHostNonce = %q, want %q", auth.DPoPHostNonce, session.DPoPHostNonce) 121 + } 122 + 123 + got, err := store.GetSession(context.Background(), did, session.SessionID) 124 + if err != nil { 125 + t.Fatalf("GetSession() error = %v", err) 126 + } 127 + if got.AccessToken != session.AccessToken { 128 + t.Fatalf("AccessToken = %q, want %q", got.AccessToken, session.AccessToken) 129 + } 130 + 131 + if err := store.DeleteSession(context.Background(), did, session.SessionID); err != nil { 132 + t.Fatalf("DeleteSession() error = %v", err) 133 + } 134 + 135 + deleted, err := store.GetSession(context.Background(), did, session.SessionID) 136 + if err == nil || deleted != nil { 137 + t.Fatalf("GetSession() after delete = (%v, %v), want error", deleted, err) 138 + } 139 + }
+1 -5
frontend/src/App.svelte
··· 119 119 } 120 120 121 121 async function performSearch(query: string, source: string) { 122 - if (!query.trim()) { 123 - query = "*"; 124 - } 125 - 126 122 isSearching = true; 127 123 try { 128 - const results = await Search(query, source); 124 + const results = await Search(query.trim(), source); 129 125 searchResults = sortResults(results); 130 126 } catch (err) { 131 127 console.error("Search failed:", err);
+5 -28
index_service.go
··· 153 153 return nil, fmt.Errorf("invalid DID: %w", err) 154 154 } 155 155 156 - redirectURI := "http://127.0.0.1/callback" 157 - scopes := []string{"atproto", "transition:generic"} 158 - config := oauth.NewLocalhostConfig(redirectURI, scopes) 159 - store := oauth.NewMemStore() 160 - 161 - sessionData := oauth.ClientSessionData{ 162 - AccountDID: did, 163 - SessionID: auth.SessionID, 164 - HostURL: auth.PDSURL, 165 - AuthServerURL: auth.AuthServerURL, 166 - AuthServerTokenEndpoint: auth.AuthServerTokenEndpoint, 167 - AuthServerRevocationEndpoint: auth.AuthServerRevocationEndpoint, 168 - AccessToken: auth.AccessJWT, 169 - RefreshToken: auth.RefreshJWT, 170 - Scopes: scopes, 171 - DPoPAuthServerNonce: auth.DPoPAuthNonce, 172 - DPoPHostNonce: auth.DPoPHostNonce, 173 - DPoPPrivateKeyMultibase: auth.DPoPPrivateKey, 174 - } 175 - 176 - if err := store.SaveSession(ctx, sessionData); err != nil { 177 - return nil, fmt.Errorf("failed to save session: %w", err) 178 - } 179 - 180 - app := oauth.NewClientApp(&config, store) 156 + store := NewSQLiteOAuthStore() 157 + app := newOAuthApp(store) 181 158 182 159 session, err := app.ResumeSession(ctx, did, auth.SessionID) 183 160 if err != nil { ··· 241 218 } 242 219 243 220 // fetchBookmarks writes bookmarks to the provided channel in batches 244 - func (c *BlueskyClient) fetchBookmarks(maxPosts int, ch chan<- *PostResult, svc *IndexService) { 221 + func (c *BlueskyClient) fetchBookmarks(maxPosts int, ch chan<- *PostResult, _ *IndexService) { 245 222 ctx := context.Background() 246 223 apiClient := c.session.APIClient() 247 224 var cursor string ··· 291 268 } 292 269 293 270 // fetchLikes writes likes to the provided channel in batches 294 - func (c *BlueskyClient) fetchLikes(maxPosts int, ch chan<- *PostResult, svc *IndexService) { 271 + func (c *BlueskyClient) fetchLikes(maxPosts int, ch chan<- *PostResult, _ *IndexService) { 295 272 ctx := context.Background() 296 273 apiClient := c.session.APIClient() 297 274 var cursor string ··· 395 372 } 396 373 397 374 // parsePostRecord extracts post data and facets from the LexiconTypeDecoder 398 - func (c *BlueskyClient) parsePostRecord(decoder interface{}) (*postRecord, string, error) { 375 + func (c *BlueskyClient) parsePostRecord(decoder any) (*postRecord, string, error) { 399 376 if decoder == nil { 400 377 return &postRecord{Text: "", CreatedAt: ""}, "", nil 401 378 }
+163
oauth_store.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "net/http" 7 + "sync" 8 + "time" 9 + 10 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + ) 13 + 14 + const oauthCallbackPort = 8787 15 + 16 + var oauthScopes = []string{"atproto", "transition:generic"} 17 + 18 + func oauthCallbackURL() string { 19 + return fmt.Sprintf("http://127.0.0.1:%d/callback", oauthCallbackPort) 20 + } 21 + 22 + func oauthConfig() oauth.ClientConfig { 23 + return oauth.NewLocalhostConfig(oauthCallbackURL(), append([]string(nil), oauthScopes...)) 24 + } 25 + 26 + func newOAuthApp(store oauth.ClientAuthStore) *oauth.ClientApp { 27 + config := oauthConfig() 28 + return oauth.NewClientApp(&config, store) 29 + } 30 + 31 + func authFromSessionData(sess *oauth.ClientSessionData, handle string) *Auth { 32 + if handle == "" { 33 + handle = sess.AccountDID.String() 34 + } 35 + 36 + return &Auth{ 37 + DID: sess.AccountDID.String(), 38 + Handle: handle, 39 + AccessJWT: sess.AccessToken, 40 + RefreshJWT: sess.RefreshToken, 41 + PDSURL: sess.HostURL, 42 + SessionID: sess.SessionID, 43 + AuthServerURL: sess.AuthServerURL, 44 + AuthServerTokenEndpoint: sess.AuthServerTokenEndpoint, 45 + AuthServerRevocationEndpoint: sess.AuthServerRevocationEndpoint, 46 + DPoPAuthNonce: sess.DPoPAuthServerNonce, 47 + DPoPHostNonce: sess.DPoPHostNonce, 48 + DPoPPrivateKey: sess.DPoPPrivateKeyMultibase, 49 + UpdatedAt: time.Now(), 50 + } 51 + } 52 + 53 + func sessionDataFromAuth(auth *Auth) (*oauth.ClientSessionData, error) { 54 + did, err := syntax.ParseDID(auth.DID) 55 + if err != nil { 56 + return nil, fmt.Errorf("invalid DID in database: %w", err) 57 + } 58 + 59 + return &oauth.ClientSessionData{ 60 + AccountDID: did, 61 + SessionID: auth.SessionID, 62 + HostURL: auth.PDSURL, 63 + AuthServerURL: auth.AuthServerURL, 64 + AuthServerTokenEndpoint: auth.AuthServerTokenEndpoint, 65 + AuthServerRevocationEndpoint: auth.AuthServerRevocationEndpoint, 66 + Scopes: append([]string(nil), oauthScopes...), 67 + AccessToken: auth.AccessJWT, 68 + RefreshToken: auth.RefreshJWT, 69 + DPoPAuthServerNonce: auth.DPoPAuthNonce, 70 + DPoPHostNonce: auth.DPoPHostNonce, 71 + DPoPPrivateKeyMultibase: auth.DPoPPrivateKey, 72 + }, nil 73 + } 74 + 75 + type SQLiteOAuthStore struct { 76 + requests map[string]oauth.AuthRequestData 77 + mu sync.Mutex 78 + } 79 + 80 + func NewSQLiteOAuthStore() *SQLiteOAuthStore { 81 + return &SQLiteOAuthStore{ 82 + requests: make(map[string]oauth.AuthRequestData), 83 + } 84 + } 85 + 86 + func (s *SQLiteOAuthStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) { 87 + auth, err := GetAuthByDID(did.String()) 88 + if err != nil { 89 + return nil, err 90 + } 91 + if auth == nil || auth.SessionID != sessionID { 92 + return nil, fmt.Errorf("session not found: %s", did) 93 + } 94 + 95 + return sessionDataFromAuth(auth) 96 + } 97 + 98 + func (s *SQLiteOAuthStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error { 99 + auth, err := GetAuthByDID(sess.AccountDID.String()) 100 + if err != nil { 101 + return err 102 + } 103 + 104 + handle := "" 105 + if auth != nil { 106 + handle = auth.Handle 107 + } 108 + 109 + return UpsertAuth(authFromSessionData(&sess, handle)) 110 + } 111 + 112 + func (s *SQLiteOAuthStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { 113 + _, err := db.ExecContext(ctx, "DELETE FROM auth WHERE did = ? AND session_id = ?", did.String(), sessionID) 114 + return err 115 + } 116 + 117 + func (s *SQLiteOAuthStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) { 118 + s.mu.Lock() 119 + defer s.mu.Unlock() 120 + 121 + info, ok := s.requests[state] 122 + if !ok { 123 + return nil, fmt.Errorf("request info not found: %s", state) 124 + } 125 + return &info, nil 126 + } 127 + 128 + func (s *SQLiteOAuthStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error { 129 + s.mu.Lock() 130 + defer s.mu.Unlock() 131 + 132 + if _, ok := s.requests[info.State]; ok { 133 + return fmt.Errorf("auth request already saved for state %s", info.State) 134 + } 135 + 136 + s.requests[info.State] = info 137 + return nil 138 + } 139 + 140 + func (s *SQLiteOAuthStore) DeleteAuthRequestInfo(ctx context.Context, state string) error { 141 + s.mu.Lock() 142 + defer s.mu.Unlock() 143 + 144 + delete(s.requests, state) 145 + return nil 146 + } 147 + 148 + func listenerAddress() string { 149 + return fmt.Sprintf("127.0.0.1:%d", oauthCallbackPort) 150 + } 151 + 152 + func closeCallbackServer(server *http.Server, listener httpCloser) { 153 + if server != nil { 154 + _ = server.Close() 155 + } 156 + if listener != nil { 157 + _ = listener.Close() 158 + } 159 + } 160 + 161 + type httpCloser interface { 162 + Close() error 163 + }