+37
-9
cmd/blup/main.go
+37
-9
cmd/blup/main.go
···
3
3
import (
4
4
"context"
5
5
"encoding/json"
6
+
"errors"
6
7
"fmt"
7
8
"io"
8
9
"log/slog"
···
14
15
15
16
"github.com/bluesky-social/indigo/api/atproto"
16
17
"github.com/bluesky-social/indigo/atproto/atclient"
17
-
lex_util "github.com/bluesky-social/indigo/lex/util"
18
+
"github.com/bluesky-social/indigo/atproto/syntax"
18
19
"github.com/spf13/cobra"
19
20
"tangled.sh/evan.jarrett.net/blup/internal/auth"
20
21
"tangled.sh/evan.jarrett.net/blup/internal/clipboard"
21
22
"tangled.sh/evan.jarrett.net/blup/internal/screenshot"
22
-
"tangled.sh/evan.jarrett.net/blup/internal/util"
23
+
"tangled.sh/evan.jarrett.net/blup/internal/ui"
23
24
)
24
25
25
26
const (
···
159
160
}
160
161
161
162
func runCapture(cmd *cobra.Command, args []string) error {
163
+
// Pre-check auth in non-interactive mode BEFORE taking screenshot.
164
+
// This prevents the confusing GNOME "screenshot captured" notification
165
+
// when upload will fail anyway due to missing auth.
166
+
if !ui.IsInteractive() && !auth.HasSavedCredentials() {
167
+
ui.NotifyLoginRequired()
168
+
return auth.ErrNoLoginIdentifier
169
+
}
170
+
162
171
// Take screenshot using XDG Desktop Portal
163
172
fmt.Println("Opening screenshot dialog...")
164
173
imagePath, err := screenshot.CaptureScreenshot(true)
···
176
185
// Copy to clipboard using wl-copy
177
186
if err := copyToClipboard(url); err != nil {
178
187
fmt.Fprintf(os.Stderr, "Warning: failed to copy to clipboard: %v\n", err)
188
+
if !ui.IsInteractive() {
189
+
ui.NotifyError("Failed to copy URL to clipboard")
190
+
}
179
191
}
180
192
181
193
fmt.Println(url)
···
228
240
229
241
// Get authenticated session (will re-auth if needed using saved login identifier)
230
242
sess, err := auth.RefreshTokens("")
243
+
if errors.Is(err, auth.ErrNoLoginIdentifier) {
244
+
if !ui.IsInteractive() {
245
+
// Non-interactive mode (keyboard shortcut) - show notification and fail
246
+
ui.NotifyLoginRequired()
247
+
return "", auth.ErrNoLoginIdentifier
248
+
}
249
+
// Interactive mode - prompt for login
250
+
fmt.Print("Enter your ATProto handle: ")
251
+
var handle string
252
+
fmt.Scanln(&handle)
253
+
handle = strings.TrimPrefix(handle, "@")
254
+
sess, err = auth.RefreshTokens(handle)
255
+
}
231
256
if err != nil {
232
-
return "", fmt.Errorf("not authenticated, run '%s login' first: %w", Name, err)
257
+
if !ui.IsInteractive() {
258
+
ui.NotifyError(err.Error())
259
+
}
260
+
return "", fmt.Errorf("authentication failed: %w", err)
233
261
}
234
262
235
263
// Get API client from session
···
295
323
return "", fmt.Errorf("failed to create record: %w", err)
296
324
}
297
325
298
-
// Extract blob CID for server URL
299
-
blob := record["blob"].(*lex_util.LexBlob)
300
-
blobCID := blob.Ref.String()
301
-
converted, err := util.ConvertCIDBase32ToBase62(blobCID)
326
+
// Extract rkey from the record URI for shorter URLs
327
+
// URI format: at://did:plc:xxx/blue.imgs.blup.image/{rkey}
328
+
uri, err := syntax.ParseATURI(recordOut.Uri)
302
329
if err != nil {
303
-
return "", err
330
+
return "", fmt.Errorf("failed to parse record URI: %w", err)
304
331
}
332
+
rkey := uri.RecordKey().String()
305
333
306
-
return fmt.Sprintf("%s/%s/%s", CDN, handle, converted), nil
334
+
return fmt.Sprintf("%s/%s/%s", CDN, handle, rkey), nil
307
335
}
308
336
309
337
func setupLogging() {
+4
-6
go.mod
+4
-6
go.mod
···
4
4
5
5
require (
6
6
github.com/bluesky-social/indigo v0.0.0-20251223190123-598fbf0e146e
7
-
//github.com/gen2brain/beeep v0.11.1
8
-
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
7
+
github.com/godbus/dbus/v5 v5.1.0
9
8
github.com/ipfs/go-cid v0.5.0
10
9
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
11
10
github.com/spf13/cobra v1.9.1
12
11
github.com/zalando/go-keyring v0.2.6
13
-
//golang.design/x/clipboard v0.7.1
12
+
golang.org/x/term v0.38.0
14
13
)
15
-
16
-
require github.com/godbus/dbus/v5 v5.1.0
17
14
18
15
require (
19
16
al.essio.dev/pkg/shellescape v1.6.0 // indirect
···
22
19
github.com/danieljoos/wincred v1.2.2 // indirect
23
20
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
24
21
github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect
22
+
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
25
23
github.com/google/go-querystring v1.1.0 // indirect
26
24
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
27
25
github.com/inconshreveable/mousetrap v1.1.0 // indirect
···
45
43
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
46
44
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
47
45
golang.org/x/crypto v0.39.0 // indirect
48
-
golang.org/x/sys v0.33.0 // indirect
46
+
golang.org/x/sys v0.39.0 // indirect
49
47
golang.org/x/time v0.8.0 // indirect
50
48
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
51
49
google.golang.org/protobuf v1.36.6 // indirect
+4
-2
go.sum
+4
-2
go.sum
···
82
82
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
83
83
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
84
84
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
85
-
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
86
-
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
85
+
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
86
+
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
87
+
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
88
+
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
87
89
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
88
90
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
89
91
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+31
internal/auth/keyring.go
+31
internal/auth/keyring.go
···
1
+
package auth
2
+
3
+
import (
4
+
"github.com/zalando/go-keyring"
5
+
)
6
+
7
+
// Keyring defines the interface for keyring operations.
8
+
// This allows mocking the system keyring for testing.
9
+
type Keyring interface {
10
+
Get(service, key string) (string, error)
11
+
Set(service, key, value string) error
12
+
Delete(service, key string) error
13
+
}
14
+
15
+
// RealKeyring implements Keyring using the system keyring.
16
+
type RealKeyring struct{}
17
+
18
+
func (r *RealKeyring) Get(service, key string) (string, error) {
19
+
return keyring.Get(service, key)
20
+
}
21
+
22
+
func (r *RealKeyring) Set(service, key, value string) error {
23
+
return keyring.Set(service, key, value)
24
+
}
25
+
26
+
func (r *RealKeyring) Delete(service, key string) error {
27
+
return keyring.Delete(service, key)
28
+
}
29
+
30
+
// DefaultKeyring is the default keyring implementation.
31
+
var DefaultKeyring Keyring = &RealKeyring{}
+119
internal/auth/metadata_test.go
+119
internal/auth/metadata_test.go
···
1
+
package auth
2
+
3
+
import (
4
+
"testing"
5
+
)
6
+
7
+
func TestGetClientMetadata(t *testing.T) {
8
+
metadata := GetClientMetadata()
9
+
10
+
// Verify required fields are populated
11
+
if metadata.ClientID == "" {
12
+
t.Error("ClientID should not be empty")
13
+
}
14
+
if metadata.ClientName == "" {
15
+
t.Error("ClientName should not be empty")
16
+
}
17
+
if metadata.ClientURI == "" {
18
+
t.Error("ClientURI should not be empty")
19
+
}
20
+
if len(metadata.RedirectURIs) == 0 {
21
+
t.Error("RedirectURIs should not be empty")
22
+
}
23
+
if len(metadata.GrantTypes) == 0 {
24
+
t.Error("GrantTypes should not be empty")
25
+
}
26
+
if metadata.Scope == "" {
27
+
t.Error("Scope should not be empty")
28
+
}
29
+
30
+
// Verify expected values from client-metadata.json
31
+
if metadata.ClientID != "https://blup.imgs.blue/oauth-client-metadata.json" {
32
+
t.Errorf("ClientID = %q, want %q", metadata.ClientID, "https://blup.imgs.blue/oauth-client-metadata.json")
33
+
}
34
+
if metadata.ClientName != "Blup" {
35
+
t.Errorf("ClientName = %q, want %q", metadata.ClientName, "Blup")
36
+
}
37
+
if metadata.ClientURI != "https://blup.imgs.blue" {
38
+
t.Errorf("ClientURI = %q, want %q", metadata.ClientURI, "https://blup.imgs.blue")
39
+
}
40
+
if metadata.RedirectURIs[0] != "https://blup.imgs.blue/oauth/callback" {
41
+
t.Errorf("RedirectURIs[0] = %q, want %q", metadata.RedirectURIs[0], "https://blup.imgs.blue/oauth/callback")
42
+
}
43
+
if !metadata.DpopBoundAccessTokens {
44
+
t.Error("DpopBoundAccessTokens should be true")
45
+
}
46
+
if metadata.TokenEndpointAuthMethod != "none" {
47
+
t.Errorf("TokenEndpointAuthMethod = %q, want %q", metadata.TokenEndpointAuthMethod, "none")
48
+
}
49
+
if metadata.ApplicationType != "native" {
50
+
t.Errorf("ApplicationType = %q, want %q", metadata.ApplicationType, "native")
51
+
}
52
+
}
53
+
54
+
func TestGetClientMetadataGrantTypes(t *testing.T) {
55
+
metadata := GetClientMetadata()
56
+
57
+
expectedGrantTypes := []string{"authorization_code", "refresh_token"}
58
+
if len(metadata.GrantTypes) != len(expectedGrantTypes) {
59
+
t.Fatalf("GrantTypes length = %d, want %d", len(metadata.GrantTypes), len(expectedGrantTypes))
60
+
}
61
+
62
+
for i, gt := range expectedGrantTypes {
63
+
if metadata.GrantTypes[i] != gt {
64
+
t.Errorf("GrantTypes[%d] = %q, want %q", i, metadata.GrantTypes[i], gt)
65
+
}
66
+
}
67
+
}
68
+
69
+
func TestGetClientMetadataResponseTypes(t *testing.T) {
70
+
metadata := GetClientMetadata()
71
+
72
+
if len(metadata.ResponseTypes) != 1 {
73
+
t.Fatalf("ResponseTypes length = %d, want 1", len(metadata.ResponseTypes))
74
+
}
75
+
if metadata.ResponseTypes[0] != "code" {
76
+
t.Errorf("ResponseTypes[0] = %q, want %q", metadata.ResponseTypes[0], "code")
77
+
}
78
+
}
79
+
80
+
func TestGetClientConfig(t *testing.T) {
81
+
config := GetClientConfig()
82
+
83
+
// Verify the config is built correctly
84
+
if config.ClientID == "" {
85
+
t.Error("ClientID should not be empty")
86
+
}
87
+
if config.CallbackURL == "" {
88
+
t.Error("CallbackURL should not be empty")
89
+
}
90
+
if len(config.Scopes) == 0 {
91
+
t.Error("Scopes should not be empty")
92
+
}
93
+
94
+
// Verify expected values
95
+
if config.ClientID != "https://blup.imgs.blue/oauth-client-metadata.json" {
96
+
t.Errorf("ClientID = %q, want %q", config.ClientID, "https://blup.imgs.blue/oauth-client-metadata.json")
97
+
}
98
+
if config.CallbackURL != "https://blup.imgs.blue/oauth/callback" {
99
+
t.Errorf("CallbackURL = %q, want %q", config.CallbackURL, "https://blup.imgs.blue/oauth/callback")
100
+
}
101
+
}
102
+
103
+
func TestGetClientConfigScopes(t *testing.T) {
104
+
config := GetClientConfig()
105
+
106
+
// Scope in metadata is "atproto repo:blue.imgs.blup.image blob:image/*"
107
+
// Should be split into 3 scopes
108
+
expectedScopes := []string{"atproto", "repo:blue.imgs.blup.image", "blob:image/*"}
109
+
110
+
if len(config.Scopes) != len(expectedScopes) {
111
+
t.Fatalf("Scopes length = %d, want %d; got %v", len(config.Scopes), len(expectedScopes), config.Scopes)
112
+
}
113
+
114
+
for i, scope := range expectedScopes {
115
+
if config.Scopes[i] != scope {
116
+
t.Errorf("Scopes[%d] = %q, want %q", i, config.Scopes[i], scope)
117
+
}
118
+
}
119
+
}
+171
-81
internal/auth/oauth.go
+171
-81
internal/auth/oauth.go
···
4
4
"bufio"
5
5
"context"
6
6
"encoding/json"
7
+
"errors"
7
8
"fmt"
8
9
"log/slog"
9
10
"net/http"
···
17
18
"github.com/pkg/browser"
18
19
)
19
20
21
+
// ErrNoLoginIdentifier is returned when no active session exists and no login identifier
22
+
// is saved. Callers should prompt the user for their handle and retry.
23
+
var ErrNoLoginIdentifier = errors.New("no active session and no login identifier provided")
24
+
25
+
// SSEAuthData holds the parsed auth completion data from SSE events.
26
+
type SSEAuthData struct {
27
+
Code string `json:"code"`
28
+
Iss string `json:"iss"`
29
+
State string `json:"state"`
30
+
}
31
+
32
+
// parseSSEAuthData parses the JSON data from an SSE auth-complete event.
33
+
func parseSSEAuthData(data string) (*SSEAuthData, error) {
34
+
data = strings.TrimSpace(data)
35
+
if data == "" {
36
+
return nil, fmt.Errorf("empty auth data")
37
+
}
38
+
39
+
var authData SSEAuthData
40
+
if err := json.Unmarshal([]byte(data), &authData); err != nil {
41
+
return nil, fmt.Errorf("failed to parse auth data: %w", err)
42
+
}
43
+
44
+
if authData.Code == "" || authData.Iss == "" || authData.State == "" {
45
+
return nil, fmt.Errorf("missing required fields in auth data")
46
+
}
47
+
48
+
return &authData, nil
49
+
}
50
+
51
+
// NewClientApp creates a new OAuth client app and keyring store.
52
+
// Optionally accepts a KeyringAuthStore for testing; if not provided, creates a real one.
53
+
func NewClientApp(stores ...*KeyringAuthStore) (*oauth.ClientApp, *KeyringAuthStore) {
54
+
var store *KeyringAuthStore
55
+
if len(stores) > 0 && stores[0] != nil {
56
+
store = stores[0]
57
+
} else {
58
+
store = NewKeyringAuthStore()
59
+
}
60
+
clientConfig := GetClientConfig()
61
+
app := oauth.NewClientApp(&clientConfig, store)
62
+
return app, store
63
+
}
64
+
65
+
// AuthenticateAndResume performs a full OAuth authentication flow and returns a resumed session.
66
+
func AuthenticateAndResume(ctx context.Context, loginIdentifier string) (*oauth.ClientSession, error) {
67
+
flow, err := NewOAuthFlow(loginIdentifier)
68
+
if err != nil {
69
+
return nil, err
70
+
}
71
+
sess, err := flow.Authenticate()
72
+
if err != nil {
73
+
return nil, err
74
+
}
75
+
app, _ := NewClientApp()
76
+
return app.ResumeSession(ctx, sess.AccountDID, sess.SessionID)
77
+
}
78
+
79
+
// ResumeCurrentSession retrieves the current session from the store and resumes it.
80
+
// Returns the session, session data, and any error.
81
+
func ResumeCurrentSession(ctx context.Context) (*oauth.ClientSession, *oauth.ClientSessionData, error) {
82
+
app, store := NewClientApp()
83
+
sessData, err := store.GetCurrentSession(ctx)
84
+
if err != nil {
85
+
return nil, nil, err
86
+
}
87
+
sess, err := app.ResumeSession(ctx, sessData.AccountDID, sessData.SessionID)
88
+
if err != nil {
89
+
return nil, sessData, err
90
+
}
91
+
return sess, sessData, nil
92
+
}
93
+
94
+
// HTTPDoer abstracts HTTP client operations for testing.
95
+
type HTTPDoer interface {
96
+
Do(req *http.Request) (*http.Response, error)
97
+
}
98
+
99
+
// BrowserOpener is a function that opens a URL in the browser.
100
+
type BrowserOpener func(url string) error
101
+
20
102
type OAuthFlow struct {
21
103
app *oauth.ClientApp
22
104
loginIdentifier string
···
24
106
authSuccess chan *oauth.ClientSessionData
25
107
authError chan error
26
108
savedState string
109
+
110
+
// Injectable dependencies (nil means use defaults)
111
+
httpClient HTTPDoer
112
+
openBrowser BrowserOpener
27
113
}
28
114
29
-
func NewOAuthFlow(loginIdentifier string) (*OAuthFlow, error) {
30
-
store := NewKeyringAuthStore()
115
+
// OAuthFlowOption configures an OAuthFlow.
116
+
type OAuthFlowOption func(*OAuthFlow)
117
+
118
+
// WithHTTPClient sets a custom HTTP client for the OAuth flow.
119
+
func WithHTTPClient(client HTTPDoer) OAuthFlowOption {
120
+
return func(f *OAuthFlow) {
121
+
f.httpClient = client
122
+
}
123
+
}
124
+
125
+
// WithBrowserOpener sets a custom browser opener for the OAuth flow.
126
+
func WithBrowserOpener(opener BrowserOpener) OAuthFlowOption {
127
+
return func(f *OAuthFlow) {
128
+
f.openBrowser = opener
129
+
}
130
+
}
131
+
132
+
// WithStore sets a custom keyring store for the OAuth flow.
133
+
func WithStore(store *KeyringAuthStore) OAuthFlowOption {
134
+
return func(f *OAuthFlow) {
135
+
f.store = store
136
+
}
137
+
}
138
+
139
+
func NewOAuthFlow(loginIdentifier string, opts ...OAuthFlowOption) (*OAuthFlow, error) {
140
+
flow := &OAuthFlow{
141
+
loginIdentifier: loginIdentifier,
142
+
authSuccess: make(chan *oauth.ClientSessionData, 1),
143
+
authError: make(chan error, 1),
144
+
}
145
+
146
+
// Apply options
147
+
for _, opt := range opts {
148
+
opt(flow)
149
+
}
150
+
151
+
// Set defaults for nil dependencies
152
+
if flow.store == nil {
153
+
flow.store = NewKeyringAuthStore()
154
+
}
155
+
if flow.httpClient == nil {
156
+
flow.httpClient = &http.Client{Timeout: 5 * time.Minute}
157
+
}
158
+
if flow.openBrowser == nil {
159
+
flow.openBrowser = browser.OpenURL
160
+
}
161
+
31
162
clientConfig := GetClientConfig()
32
163
33
164
// Debug: show what we're requesting
···
39
170
"scope_raw", metadata.Scope,
40
171
)
41
172
42
-
app := oauth.NewClientApp(&clientConfig, store)
173
+
flow.app = oauth.NewClientApp(&clientConfig, flow.store)
43
174
44
-
return &OAuthFlow{
45
-
app: app,
46
-
loginIdentifier: loginIdentifier,
47
-
store: store,
48
-
authSuccess: make(chan *oauth.ClientSessionData, 1),
49
-
authError: make(chan error, 1),
50
-
}, nil
175
+
return flow, nil
51
176
}
52
177
53
178
func (f *OAuthFlow) Authenticate() (*oauth.ClientSessionData, error) {
···
75
200
76
201
// Open browser to authorization URL
77
202
fmt.Printf("Opening browser for authentication...\n")
78
-
if err := browser.OpenURL(redirectURL); err != nil {
203
+
if err := f.openBrowser(redirectURL); err != nil {
79
204
fmt.Printf("Failed to open browser automatically.\n")
80
205
fmt.Printf("Please open this URL manually:\n%s\n", redirectURL)
81
206
}
···
119
244
req.Header.Set("Accept", "text/event-stream")
120
245
req.Header.Set("Cache-Control", "no-cache")
121
246
122
-
client := &http.Client{Timeout: 5 * time.Minute}
123
-
resp, err := client.Do(req)
247
+
resp, err := f.httpClient.Do(req)
124
248
if err != nil {
125
249
slog.Debug("SSE connection error", "error", err)
126
250
f.authError <- err
···
149
273
dataLine, _ := reader.ReadString('\n')
150
274
slog.Debug("SSE auth data", "data", dataLine)
151
275
if after, ok := strings.CutPrefix(dataLine, "data: "); ok {
152
-
data := after
153
-
var authData struct {
154
-
Code string `json:"code"`
155
-
Iss string `json:"iss"`
156
-
State string `json:"state"`
157
-
}
158
-
if err := json.Unmarshal([]byte(data), &authData); err != nil {
159
-
f.authError <- fmt.Errorf("failed to parse auth data: %w", err)
276
+
authData, err := parseSSEAuthData(after)
277
+
if err != nil {
278
+
f.authError <- err
160
279
return
161
280
}
162
281
slog.Debug("SSE auth complete", "iss", authData.Iss, "state", authData.State)
···
201
320
202
321
// GetSession retrieves the current session, refreshing tokens if needed
203
322
func GetSession() (*oauth.ClientSession, error) {
204
-
ctx := context.Background()
205
-
store := NewKeyringAuthStore()
206
-
207
-
// Check for current session
208
-
sessData, err := store.GetCurrentSession(ctx)
209
-
if err != nil {
210
-
return nil, fmt.Errorf("no active session")
211
-
}
212
-
213
-
clientConfig := GetClientConfig()
214
-
app := oauth.NewClientApp(&clientConfig, store)
215
-
216
-
// Resume the session
217
-
sess, err := app.ResumeSession(ctx, sessData.AccountDID, sessData.SessionID)
323
+
sess, _, err := ResumeCurrentSession(context.Background())
218
324
if err != nil {
219
325
return nil, fmt.Errorf("failed to resume session: %w", err)
220
326
}
221
-
222
327
return sess, nil
223
328
}
224
329
···
227
332
// If empty, it will use the saved login identifier from keyring
228
333
func RefreshTokens(loginIdentifier string) (*oauth.ClientSession, error) {
229
334
ctx := context.Background()
230
-
store := NewKeyringAuthStore()
231
335
232
-
// Check for current session
233
-
sessData, err := store.GetCurrentSession(ctx)
234
-
if err != nil {
235
-
// No session, need to authenticate
236
-
if loginIdentifier == "" {
237
-
return nil, fmt.Errorf("no active session and no login identifier provided")
238
-
}
239
-
flow, err := NewOAuthFlow(loginIdentifier)
240
-
if err != nil {
241
-
return nil, err
242
-
}
243
-
sess, err := flow.Authenticate()
336
+
// Try to resume current session
337
+
sess, _, err := ResumeCurrentSession(ctx)
338
+
if err == nil {
339
+
return sess, nil
340
+
}
341
+
342
+
// Session doesn't exist or failed to resume - need to authenticate
343
+
// Get login identifier from keyring if not provided
344
+
if loginIdentifier == "" {
345
+
_, store := NewClientApp()
346
+
loginIdentifier, err = store.GetLoginIdentifier()
244
347
if err != nil {
245
-
return nil, err
348
+
return nil, ErrNoLoginIdentifier
246
349
}
247
-
// Resume the session to return a ClientSession
248
-
clientConfig := GetClientConfig()
249
-
app := oauth.NewClientApp(&clientConfig, store)
250
-
return app.ResumeSession(ctx, sess.AccountDID, sess.SessionID)
251
350
}
252
351
253
-
clientConfig := GetClientConfig()
254
-
app := oauth.NewClientApp(&clientConfig, store)
352
+
return AuthenticateAndResume(ctx, loginIdentifier)
353
+
}
354
+
355
+
// HasSavedCredentials returns true if there's a saved session or login identifier.
356
+
// This is a lightweight check that doesn't attempt authentication.
357
+
func HasSavedCredentials() bool {
358
+
ctx := context.Background()
359
+
store := NewKeyringAuthStore()
360
+
361
+
// Check for existing session
362
+
if _, err := store.GetCurrentSession(ctx); err == nil {
363
+
return true
364
+
}
255
365
256
-
// Resume the session - this will auto-refresh tokens on 401
257
-
sess, err := app.ResumeSession(ctx, sessData.AccountDID, sessData.SessionID)
258
-
if err != nil {
259
-
// Session invalid, need to re-authenticate
260
-
// Get login identifier from keyring if not provided
261
-
if loginIdentifier == "" {
262
-
loginIdentifier, err = store.GetLoginIdentifier()
263
-
if err != nil {
264
-
return nil, fmt.Errorf("session expired and no saved login identifier: %w", err)
265
-
}
266
-
}
267
-
flow, err := NewOAuthFlow(loginIdentifier)
268
-
if err != nil {
269
-
return nil, err
270
-
}
271
-
newSess, err := flow.Authenticate()
272
-
if err != nil {
273
-
return nil, err
274
-
}
275
-
return app.ResumeSession(ctx, newSess.AccountDID, newSess.SessionID)
366
+
// Check for saved login identifier (can be used to re-authenticate)
367
+
if _, err := store.GetLoginIdentifier(); err == nil {
368
+
return true
276
369
}
277
370
278
-
return sess, nil
371
+
return false
279
372
}
280
373
281
374
// Logout revokes tokens and clears the session
282
375
func Logout() error {
283
376
ctx := context.Background()
284
-
store := NewKeyringAuthStore()
377
+
app, store := NewClientApp()
285
378
286
379
sessData, err := store.GetCurrentSession(ctx)
287
380
if err != nil {
288
381
// No session to logout
289
382
return nil
290
383
}
291
-
292
-
clientConfig := GetClientConfig()
293
-
app := oauth.NewClientApp(&clientConfig, store)
294
384
295
385
// Logout revokes tokens and deletes session
296
386
if err := app.Logout(ctx, sessData.AccountDID, sessData.SessionID); err != nil {
+246
internal/auth/oauth_test.go
+246
internal/auth/oauth_test.go
···
1
+
package auth
2
+
3
+
import (
4
+
"net/http"
5
+
"testing"
6
+
)
7
+
8
+
func TestNewOAuthFlow(t *testing.T) {
9
+
flow, err := NewOAuthFlow("test.bsky.social")
10
+
if err != nil {
11
+
t.Fatalf("NewOAuthFlow() error = %v", err)
12
+
}
13
+
14
+
if flow == nil {
15
+
t.Fatal("NewOAuthFlow() returned nil")
16
+
}
17
+
18
+
if flow.loginIdentifier != "test.bsky.social" {
19
+
t.Errorf("loginIdentifier = %q, want %q", flow.loginIdentifier, "test.bsky.social")
20
+
}
21
+
22
+
if flow.app == nil {
23
+
t.Error("app should not be nil")
24
+
}
25
+
26
+
if flow.store == nil {
27
+
t.Error("store should not be nil")
28
+
}
29
+
30
+
if flow.authSuccess == nil {
31
+
t.Error("authSuccess channel should not be nil")
32
+
}
33
+
34
+
if flow.authError == nil {
35
+
t.Error("authError channel should not be nil")
36
+
}
37
+
38
+
if flow.httpClient == nil {
39
+
t.Error("httpClient should not be nil (default)")
40
+
}
41
+
42
+
if flow.openBrowser == nil {
43
+
t.Error("openBrowser should not be nil (default)")
44
+
}
45
+
}
46
+
47
+
func TestNewOAuthFlowEmptyIdentifier(t *testing.T) {
48
+
flow, err := NewOAuthFlow("")
49
+
if err != nil {
50
+
t.Fatalf("NewOAuthFlow() error = %v", err)
51
+
}
52
+
53
+
if flow == nil {
54
+
t.Fatal("NewOAuthFlow() returned nil")
55
+
}
56
+
57
+
if flow.loginIdentifier != "" {
58
+
t.Errorf("loginIdentifier = %q, want empty string", flow.loginIdentifier)
59
+
}
60
+
}
61
+
62
+
func TestAuthenticateRequiresIdentifier(t *testing.T) {
63
+
flow, err := NewOAuthFlow("")
64
+
if err != nil {
65
+
t.Fatalf("NewOAuthFlow() error = %v", err)
66
+
}
67
+
68
+
_, err = flow.Authenticate()
69
+
if err == nil {
70
+
t.Error("Authenticate() expected error with empty identifier, got nil")
71
+
}
72
+
73
+
expectedErr := "login identifier is required"
74
+
if err.Error() != expectedErr {
75
+
t.Errorf("Authenticate() error = %q, want %q", err.Error(), expectedErr)
76
+
}
77
+
}
78
+
79
+
// MockHTTPClient implements HTTPDoer for testing
80
+
type MockHTTPClient struct {
81
+
DoFunc func(req *http.Request) (*http.Response, error)
82
+
}
83
+
84
+
func (m *MockHTTPClient) Do(req *http.Request) (*http.Response, error) {
85
+
return m.DoFunc(req)
86
+
}
87
+
88
+
func TestNewOAuthFlowWithOptions(t *testing.T) {
89
+
mockStore := NewKeyringAuthStoreWithKeyring(NewMockKeyring())
90
+
mockHTTP := &MockHTTPClient{}
91
+
browserCalled := false
92
+
mockBrowser := func(url string) error {
93
+
browserCalled = true
94
+
return nil
95
+
}
96
+
97
+
flow, err := NewOAuthFlow("test.bsky.social",
98
+
WithStore(mockStore),
99
+
WithHTTPClient(mockHTTP),
100
+
WithBrowserOpener(mockBrowser),
101
+
)
102
+
if err != nil {
103
+
t.Fatalf("NewOAuthFlow() error = %v", err)
104
+
}
105
+
106
+
if flow.store != mockStore {
107
+
t.Error("store was not injected correctly")
108
+
}
109
+
110
+
if flow.httpClient != mockHTTP {
111
+
t.Error("httpClient was not injected correctly")
112
+
}
113
+
114
+
// Test browser opener was injected
115
+
flow.openBrowser("http://test.com")
116
+
if !browserCalled {
117
+
t.Error("openBrowser was not injected correctly")
118
+
}
119
+
}
120
+
121
+
func TestNewClientAppWithStore(t *testing.T) {
122
+
mockKeyring := NewMockKeyring()
123
+
mockStore := NewKeyringAuthStoreWithKeyring(mockKeyring)
124
+
125
+
app, store := NewClientApp(mockStore)
126
+
127
+
if app == nil {
128
+
t.Error("NewClientApp() app should not be nil")
129
+
}
130
+
131
+
if store != mockStore {
132
+
t.Error("NewClientApp() should return the injected store")
133
+
}
134
+
}
135
+
136
+
func TestNewClientAppWithoutStore(t *testing.T) {
137
+
app, store := NewClientApp()
138
+
139
+
if app == nil {
140
+
t.Error("NewClientApp() app should not be nil")
141
+
}
142
+
143
+
if store == nil {
144
+
t.Error("NewClientApp() should create a default store")
145
+
}
146
+
}
147
+
148
+
func TestParseSSEAuthData(t *testing.T) {
149
+
tests := []struct {
150
+
name string
151
+
input string
152
+
wantErr bool
153
+
code string
154
+
iss string
155
+
state string
156
+
}{
157
+
{
158
+
name: "valid auth data",
159
+
input: `{"code":"auth_code_123","iss":"https://bsky.social","state":"state_abc"}`,
160
+
wantErr: false,
161
+
code: "auth_code_123",
162
+
iss: "https://bsky.social",
163
+
state: "state_abc",
164
+
},
165
+
{
166
+
name: "valid with whitespace",
167
+
input: ` {"code":"code","iss":"iss","state":"state"} `,
168
+
wantErr: false,
169
+
code: "code",
170
+
iss: "iss",
171
+
state: "state",
172
+
},
173
+
{
174
+
name: "empty string",
175
+
input: "",
176
+
wantErr: true,
177
+
},
178
+
{
179
+
name: "whitespace only",
180
+
input: " ",
181
+
wantErr: true,
182
+
},
183
+
{
184
+
name: "invalid JSON",
185
+
input: "not json",
186
+
wantErr: true,
187
+
},
188
+
{
189
+
name: "missing code",
190
+
input: `{"iss":"https://bsky.social","state":"state_abc"}`,
191
+
wantErr: true,
192
+
},
193
+
{
194
+
name: "missing iss",
195
+
input: `{"code":"auth_code_123","state":"state_abc"}`,
196
+
wantErr: true,
197
+
},
198
+
{
199
+
name: "missing state",
200
+
input: `{"code":"auth_code_123","iss":"https://bsky.social"}`,
201
+
wantErr: true,
202
+
},
203
+
{
204
+
name: "empty code",
205
+
input: `{"code":"","iss":"https://bsky.social","state":"state_abc"}`,
206
+
wantErr: true,
207
+
},
208
+
}
209
+
210
+
for _, tt := range tests {
211
+
t.Run(tt.name, func(t *testing.T) {
212
+
result, err := parseSSEAuthData(tt.input)
213
+
214
+
if tt.wantErr {
215
+
if err == nil {
216
+
t.Errorf("parseSSEAuthData() expected error, got nil")
217
+
}
218
+
return
219
+
}
220
+
221
+
if err != nil {
222
+
t.Errorf("parseSSEAuthData() unexpected error: %v", err)
223
+
return
224
+
}
225
+
226
+
if result.Code != tt.code {
227
+
t.Errorf("Code = %q, want %q", result.Code, tt.code)
228
+
}
229
+
if result.Iss != tt.iss {
230
+
t.Errorf("Iss = %q, want %q", result.Iss, tt.iss)
231
+
}
232
+
if result.State != tt.state {
233
+
t.Errorf("State = %q, want %q", result.State, tt.state)
234
+
}
235
+
})
236
+
}
237
+
}
238
+
239
+
func TestLogoutNoSession(t *testing.T) {
240
+
// Logout should succeed even when no session exists
241
+
// Note: This uses the real keyring, but should still work
242
+
// because it handles the "no session" case gracefully
243
+
err := Logout()
244
+
// We can't easily test this without mocking, but we can verify it doesn't panic
245
+
_ = err
246
+
}
+26
-18
internal/auth/storage.go
+26
-18
internal/auth/storage.go
···
7
7
8
8
"github.com/bluesky-social/indigo/atproto/auth/oauth"
9
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
-
"github.com/zalando/go-keyring"
11
10
)
12
11
13
12
const (
···
20
19
)
21
20
22
21
// KeyringAuthStore implements oauth.ClientAuthStore using the system keyring
23
-
type KeyringAuthStore struct{}
22
+
type KeyringAuthStore struct {
23
+
keyring Keyring
24
+
}
24
25
26
+
// NewKeyringAuthStore creates a new KeyringAuthStore using the system keyring.
25
27
func NewKeyringAuthStore() *KeyringAuthStore {
26
-
return &KeyringAuthStore{}
28
+
return &KeyringAuthStore{keyring: DefaultKeyring}
29
+
}
30
+
31
+
// NewKeyringAuthStoreWithKeyring creates a KeyringAuthStore with a custom Keyring implementation.
32
+
// This is useful for testing with a mock keyring.
33
+
func NewKeyringAuthStoreWithKeyring(kr Keyring) *KeyringAuthStore {
34
+
return &KeyringAuthStore{keyring: kr}
27
35
}
28
36
29
37
// sessionKey creates the keyring key for a session
···
33
41
34
42
// GetSession retrieves a session from the keyring
35
43
func (s *KeyringAuthStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) {
36
-
data, err := keyring.Get(keyringService, sessionKey(did, sessionID))
44
+
data, err := s.keyring.Get(keyringService, sessionKey(did, sessionID))
37
45
if err != nil {
38
46
return nil, err
39
47
}
···
53
61
return err
54
62
}
55
63
56
-
return keyring.Set(keyringService, sessionKey(sess.AccountDID, sess.SessionID), string(data))
64
+
return s.keyring.Set(keyringService, sessionKey(sess.AccountDID, sess.SessionID), string(data))
57
65
}
58
66
59
67
// DeleteSession removes a session from the keyring
60
68
func (s *KeyringAuthStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error {
61
-
return keyring.Delete(keyringService, sessionKey(did, sessionID))
69
+
return s.keyring.Delete(keyringService, sessionKey(did, sessionID))
62
70
}
63
71
64
72
// authRequestKey creates the keyring key for an auth request
···
68
76
69
77
// GetAuthRequestInfo retrieves pending auth request info
70
78
func (s *KeyringAuthStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) {
71
-
data, err := keyring.Get(keyringService, authRequestKey(state))
79
+
data, err := s.keyring.Get(keyringService, authRequestKey(state))
72
80
if err != nil {
73
81
return nil, err
74
82
}
···
89
97
}
90
98
91
99
// Save the auth request data
92
-
if err := keyring.Set(keyringService, authRequestKey(info.State), string(data)); err != nil {
100
+
if err := s.keyring.Set(keyringService, authRequestKey(info.State), string(data)); err != nil {
93
101
return err
94
102
}
95
103
96
104
// Also save the state as the current pending auth (for SSE correlation)
97
-
return keyring.Set(keyringService, pendingAuthStateKey, info.State)
105
+
return s.keyring.Set(keyringService, pendingAuthStateKey, info.State)
98
106
}
99
107
100
108
// GetPendingAuthState returns the state of the current pending auth request
101
109
func (s *KeyringAuthStore) GetPendingAuthState() (string, error) {
102
-
return keyring.Get(keyringService, pendingAuthStateKey)
110
+
return s.keyring.Get(keyringService, pendingAuthStateKey)
103
111
}
104
112
105
113
// ClearPendingAuthState removes the pending auth state
106
114
func (s *KeyringAuthStore) ClearPendingAuthState() error {
107
-
return keyring.Delete(keyringService, pendingAuthStateKey)
115
+
return s.keyring.Delete(keyringService, pendingAuthStateKey)
108
116
}
109
117
110
118
// DeleteAuthRequestInfo removes pending auth request info
111
119
func (s *KeyringAuthStore) DeleteAuthRequestInfo(ctx context.Context, state string) error {
112
-
return keyring.Delete(keyringService, authRequestKey(state))
120
+
return s.keyring.Delete(keyringService, authRequestKey(state))
113
121
}
114
122
115
123
// CurrentSessionRef stores reference to the current active session
···
120
128
121
129
// GetCurrentSession retrieves the current active session for the CLI
122
130
func (s *KeyringAuthStore) GetCurrentSession(ctx context.Context) (*oauth.ClientSessionData, error) {
123
-
refData, err := keyring.Get(keyringService, currentSessionKey)
131
+
refData, err := s.keyring.Get(keyringService, currentSessionKey)
124
132
if err != nil {
125
133
return nil, err
126
134
}
···
150
158
return err
151
159
}
152
160
153
-
return keyring.Set(keyringService, currentSessionKey, string(data))
161
+
return s.keyring.Set(keyringService, currentSessionKey, string(data))
154
162
}
155
163
156
164
// ClearCurrentSession removes the current session reference
157
165
func (s *KeyringAuthStore) ClearCurrentSession() error {
158
-
return keyring.Delete(keyringService, currentSessionKey)
166
+
return s.keyring.Delete(keyringService, currentSessionKey)
159
167
}
160
168
161
169
// GetLoginIdentifier retrieves the stored login identifier (handle or PDS URL)
162
170
func (s *KeyringAuthStore) GetLoginIdentifier() (string, error) {
163
-
return keyring.Get(keyringService, loginIdentifierKey)
171
+
return s.keyring.Get(keyringService, loginIdentifierKey)
164
172
}
165
173
166
174
// SetLoginIdentifier stores the login identifier for re-authentication
167
175
func (s *KeyringAuthStore) SetLoginIdentifier(id string) error {
168
-
return keyring.Set(keyringService, loginIdentifierKey, id)
176
+
return s.keyring.Set(keyringService, loginIdentifierKey, id)
169
177
}
170
178
171
179
// ClearLoginIdentifier removes the stored login identifier
172
180
func (s *KeyringAuthStore) ClearLoginIdentifier() error {
173
-
return keyring.Delete(keyringService, loginIdentifierKey)
181
+
return s.keyring.Delete(keyringService, loginIdentifierKey)
174
182
}
+318
internal/auth/storage_test.go
+318
internal/auth/storage_test.go
···
1
+
package auth
2
+
3
+
import (
4
+
"context"
5
+
"errors"
6
+
"testing"
7
+
8
+
"github.com/bluesky-social/indigo/atproto/auth/oauth"
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
10
+
)
11
+
12
+
// MockKeyring is an in-memory implementation of Keyring for testing.
13
+
type MockKeyring struct {
14
+
data map[string]map[string]string // service -> key -> value
15
+
}
16
+
17
+
func NewMockKeyring() *MockKeyring {
18
+
return &MockKeyring{
19
+
data: make(map[string]map[string]string),
20
+
}
21
+
}
22
+
23
+
var ErrNotFound = errors.New("secret not found in keyring")
24
+
25
+
func (m *MockKeyring) Get(service, key string) (string, error) {
26
+
if svc, ok := m.data[service]; ok {
27
+
if val, ok := svc[key]; ok {
28
+
return val, nil
29
+
}
30
+
}
31
+
return "", ErrNotFound
32
+
}
33
+
34
+
func (m *MockKeyring) Set(service, key, value string) error {
35
+
if _, ok := m.data[service]; !ok {
36
+
m.data[service] = make(map[string]string)
37
+
}
38
+
m.data[service][key] = value
39
+
return nil
40
+
}
41
+
42
+
func (m *MockKeyring) Delete(service, key string) error {
43
+
if svc, ok := m.data[service]; ok {
44
+
delete(svc, key)
45
+
}
46
+
return nil
47
+
}
48
+
49
+
func TestSessionKey(t *testing.T) {
50
+
did, _ := syntax.ParseDID("did:plc:test123")
51
+
key := sessionKey(did, "session-abc")
52
+
expected := "session:did:plc:test123:session-abc"
53
+
if key != expected {
54
+
t.Errorf("sessionKey() = %q, want %q", key, expected)
55
+
}
56
+
}
57
+
58
+
func TestAuthRequestKey(t *testing.T) {
59
+
key := authRequestKey("state123")
60
+
expected := "auth-request:state123"
61
+
if key != expected {
62
+
t.Errorf("authRequestKey() = %q, want %q", key, expected)
63
+
}
64
+
}
65
+
66
+
func TestSaveAndGetSession(t *testing.T) {
67
+
mock := NewMockKeyring()
68
+
store := NewKeyringAuthStoreWithKeyring(mock)
69
+
ctx := context.Background()
70
+
71
+
did, _ := syntax.ParseDID("did:plc:testuser")
72
+
sess := oauth.ClientSessionData{
73
+
AccountDID: did,
74
+
SessionID: "test-session-id",
75
+
}
76
+
77
+
// Save session
78
+
if err := store.SaveSession(ctx, sess); err != nil {
79
+
t.Fatalf("SaveSession() error = %v", err)
80
+
}
81
+
82
+
// Get session
83
+
retrieved, err := store.GetSession(ctx, did, "test-session-id")
84
+
if err != nil {
85
+
t.Fatalf("GetSession() error = %v", err)
86
+
}
87
+
88
+
if retrieved.AccountDID.String() != did.String() {
89
+
t.Errorf("GetSession() DID = %q, want %q", retrieved.AccountDID.String(), did.String())
90
+
}
91
+
if retrieved.SessionID != "test-session-id" {
92
+
t.Errorf("GetSession() SessionID = %q, want %q", retrieved.SessionID, "test-session-id")
93
+
}
94
+
}
95
+
96
+
func TestDeleteSession(t *testing.T) {
97
+
mock := NewMockKeyring()
98
+
store := NewKeyringAuthStoreWithKeyring(mock)
99
+
ctx := context.Background()
100
+
101
+
did, _ := syntax.ParseDID("did:plc:testuser")
102
+
sess := oauth.ClientSessionData{
103
+
AccountDID: did,
104
+
SessionID: "test-session-id",
105
+
}
106
+
107
+
// Save then delete
108
+
store.SaveSession(ctx, sess)
109
+
if err := store.DeleteSession(ctx, did, "test-session-id"); err != nil {
110
+
t.Fatalf("DeleteSession() error = %v", err)
111
+
}
112
+
113
+
// Should not be found
114
+
_, err := store.GetSession(ctx, did, "test-session-id")
115
+
if err == nil {
116
+
t.Error("GetSession() expected error after delete, got nil")
117
+
}
118
+
}
119
+
120
+
func TestSaveAndGetAuthRequestInfo(t *testing.T) {
121
+
mock := NewMockKeyring()
122
+
store := NewKeyringAuthStoreWithKeyring(mock)
123
+
ctx := context.Background()
124
+
125
+
info := oauth.AuthRequestData{
126
+
State: "test-state-123",
127
+
}
128
+
129
+
// Save auth request
130
+
if err := store.SaveAuthRequestInfo(ctx, info); err != nil {
131
+
t.Fatalf("SaveAuthRequestInfo() error = %v", err)
132
+
}
133
+
134
+
// Get auth request
135
+
retrieved, err := store.GetAuthRequestInfo(ctx, "test-state-123")
136
+
if err != nil {
137
+
t.Fatalf("GetAuthRequestInfo() error = %v", err)
138
+
}
139
+
140
+
if retrieved.State != "test-state-123" {
141
+
t.Errorf("GetAuthRequestInfo() State = %q, want %q", retrieved.State, "test-state-123")
142
+
}
143
+
144
+
// Pending auth state should also be set
145
+
state, err := store.GetPendingAuthState()
146
+
if err != nil {
147
+
t.Fatalf("GetPendingAuthState() error = %v", err)
148
+
}
149
+
if state != "test-state-123" {
150
+
t.Errorf("GetPendingAuthState() = %q, want %q", state, "test-state-123")
151
+
}
152
+
}
153
+
154
+
func TestDeleteAuthRequestInfo(t *testing.T) {
155
+
mock := NewMockKeyring()
156
+
store := NewKeyringAuthStoreWithKeyring(mock)
157
+
ctx := context.Background()
158
+
159
+
info := oauth.AuthRequestData{
160
+
State: "test-state-456",
161
+
}
162
+
163
+
store.SaveAuthRequestInfo(ctx, info)
164
+
if err := store.DeleteAuthRequestInfo(ctx, "test-state-456"); err != nil {
165
+
t.Fatalf("DeleteAuthRequestInfo() error = %v", err)
166
+
}
167
+
168
+
_, err := store.GetAuthRequestInfo(ctx, "test-state-456")
169
+
if err == nil {
170
+
t.Error("GetAuthRequestInfo() expected error after delete, got nil")
171
+
}
172
+
}
173
+
174
+
func TestClearPendingAuthState(t *testing.T) {
175
+
mock := NewMockKeyring()
176
+
store := NewKeyringAuthStoreWithKeyring(mock)
177
+
ctx := context.Background()
178
+
179
+
info := oauth.AuthRequestData{
180
+
State: "test-state-789",
181
+
}
182
+
store.SaveAuthRequestInfo(ctx, info)
183
+
184
+
if err := store.ClearPendingAuthState(); err != nil {
185
+
t.Fatalf("ClearPendingAuthState() error = %v", err)
186
+
}
187
+
188
+
_, err := store.GetPendingAuthState()
189
+
if err == nil {
190
+
t.Error("GetPendingAuthState() expected error after clear, got nil")
191
+
}
192
+
}
193
+
194
+
func TestSetAndGetCurrentSession(t *testing.T) {
195
+
mock := NewMockKeyring()
196
+
store := NewKeyringAuthStoreWithKeyring(mock)
197
+
ctx := context.Background()
198
+
199
+
did, _ := syntax.ParseDID("did:plc:currentuser")
200
+
sess := oauth.ClientSessionData{
201
+
AccountDID: did,
202
+
SessionID: "current-session-id",
203
+
}
204
+
205
+
// Save the actual session first
206
+
if err := store.SaveSession(ctx, sess); err != nil {
207
+
t.Fatalf("SaveSession() error = %v", err)
208
+
}
209
+
210
+
// Set as current session
211
+
if err := store.SetCurrentSession(ctx, &sess); err != nil {
212
+
t.Fatalf("SetCurrentSession() error = %v", err)
213
+
}
214
+
215
+
// Get current session
216
+
retrieved, err := store.GetCurrentSession(ctx)
217
+
if err != nil {
218
+
t.Fatalf("GetCurrentSession() error = %v", err)
219
+
}
220
+
221
+
if retrieved.AccountDID.String() != did.String() {
222
+
t.Errorf("GetCurrentSession() DID = %q, want %q", retrieved.AccountDID.String(), did.String())
223
+
}
224
+
if retrieved.SessionID != "current-session-id" {
225
+
t.Errorf("GetCurrentSession() SessionID = %q, want %q", retrieved.SessionID, "current-session-id")
226
+
}
227
+
}
228
+
229
+
func TestClearCurrentSession(t *testing.T) {
230
+
mock := NewMockKeyring()
231
+
store := NewKeyringAuthStoreWithKeyring(mock)
232
+
ctx := context.Background()
233
+
234
+
did, _ := syntax.ParseDID("did:plc:currentuser")
235
+
sess := oauth.ClientSessionData{
236
+
AccountDID: did,
237
+
SessionID: "current-session-id",
238
+
}
239
+
240
+
store.SaveSession(ctx, sess)
241
+
store.SetCurrentSession(ctx, &sess)
242
+
243
+
if err := store.ClearCurrentSession(); err != nil {
244
+
t.Fatalf("ClearCurrentSession() error = %v", err)
245
+
}
246
+
247
+
_, err := store.GetCurrentSession(ctx)
248
+
if err == nil {
249
+
t.Error("GetCurrentSession() expected error after clear, got nil")
250
+
}
251
+
}
252
+
253
+
func TestSetAndGetLoginIdentifier(t *testing.T) {
254
+
mock := NewMockKeyring()
255
+
store := NewKeyringAuthStoreWithKeyring(mock)
256
+
257
+
if err := store.SetLoginIdentifier("user.bsky.social"); err != nil {
258
+
t.Fatalf("SetLoginIdentifier() error = %v", err)
259
+
}
260
+
261
+
id, err := store.GetLoginIdentifier()
262
+
if err != nil {
263
+
t.Fatalf("GetLoginIdentifier() error = %v", err)
264
+
}
265
+
266
+
if id != "user.bsky.social" {
267
+
t.Errorf("GetLoginIdentifier() = %q, want %q", id, "user.bsky.social")
268
+
}
269
+
}
270
+
271
+
func TestClearLoginIdentifier(t *testing.T) {
272
+
mock := NewMockKeyring()
273
+
store := NewKeyringAuthStoreWithKeyring(mock)
274
+
275
+
store.SetLoginIdentifier("user.bsky.social")
276
+
277
+
if err := store.ClearLoginIdentifier(); err != nil {
278
+
t.Fatalf("ClearLoginIdentifier() error = %v", err)
279
+
}
280
+
281
+
_, err := store.GetLoginIdentifier()
282
+
if err == nil {
283
+
t.Error("GetLoginIdentifier() expected error after clear, got nil")
284
+
}
285
+
}
286
+
287
+
func TestGetSessionNotFound(t *testing.T) {
288
+
mock := NewMockKeyring()
289
+
store := NewKeyringAuthStoreWithKeyring(mock)
290
+
ctx := context.Background()
291
+
292
+
did, _ := syntax.ParseDID("did:plc:nonexistent")
293
+
_, err := store.GetSession(ctx, did, "no-such-session")
294
+
if err == nil {
295
+
t.Error("GetSession() expected error for non-existent session, got nil")
296
+
}
297
+
}
298
+
299
+
func TestGetCurrentSessionNotFound(t *testing.T) {
300
+
mock := NewMockKeyring()
301
+
store := NewKeyringAuthStoreWithKeyring(mock)
302
+
ctx := context.Background()
303
+
304
+
_, err := store.GetCurrentSession(ctx)
305
+
if err == nil {
306
+
t.Error("GetCurrentSession() expected error when no current session, got nil")
307
+
}
308
+
}
309
+
310
+
func TestGetLoginIdentifierNotFound(t *testing.T) {
311
+
mock := NewMockKeyring()
312
+
store := NewKeyringAuthStoreWithKeyring(mock)
313
+
314
+
_, err := store.GetLoginIdentifier()
315
+
if err == nil {
316
+
t.Error("GetLoginIdentifier() expected error when not set, got nil")
317
+
}
318
+
}
+47
internal/ui/notification.go
+47
internal/ui/notification.go
···
1
+
package ui
2
+
3
+
import (
4
+
"github.com/godbus/dbus/v5"
5
+
)
6
+
7
+
const (
8
+
notifyService = "org.freedesktop.Notifications"
9
+
notifyPath = "/org/freedesktop/Notifications"
10
+
notifyInterface = "org.freedesktop.Notifications"
11
+
)
12
+
13
+
// Notify sends a desktop notification via DBus
14
+
func Notify(title, message, icon string) error {
15
+
conn, err := dbus.ConnectSessionBus()
16
+
if err != nil {
17
+
return err
18
+
}
19
+
defer conn.Close()
20
+
21
+
obj := conn.Object(notifyService, notifyPath)
22
+
call := obj.Call(notifyInterface+".Notify", 0,
23
+
"blup", // app_name
24
+
uint32(0), // replaces_id
25
+
icon, // app_icon
26
+
title, // summary
27
+
message, // body
28
+
[]string{}, // actions
29
+
map[string]dbus.Variant{}, // hints
30
+
int32(5000), // expire_timeout (ms)
31
+
)
32
+
return call.Err
33
+
}
34
+
35
+
// NotifyLoginRequired shows a notification telling user to run blup login
36
+
func NotifyLoginRequired() error {
37
+
return Notify(
38
+
"blup: Login Required",
39
+
"Run 'blup login' in a terminal to authenticate.",
40
+
"dialog-password",
41
+
)
42
+
}
43
+
44
+
// NotifyError shows an error notification
45
+
func NotifyError(message string) error {
46
+
return Notify("blup: Error", message, "dialog-error")
47
+
}