···33import (
44 _ "embed"
55 "encoding/json"
66+ "strings"
77+88+ "github.com/bluesky-social/indigo/atproto/auth/oauth"
69)
710811// NOTE: if the server-side metadata changes, this file will also need to change.
1212+//
913//go:embed client-metadata.json
1014var metadataJson []byte
1515+1116type ClientMetadata struct {
1217 ClientID string `json:"client_id"`
1318 ClientName string `json:"client_name"`
···2328 TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
2429 TokenEndpointAuthSigningAlg string `json:"token_endpoint_auth_signing_alg"`
2530}
2626-27312832func GetClientMetadata() ClientMetadata {
2933 var cm ClientMetadata
3034 json.Unmarshal(metadataJson, &cm)
3135 return cm
3232-}3636+}
3737+3838+// GetClientConfig returns the OAuth client configuration for indigo
3939+func GetClientConfig() oauth.ClientConfig {
4040+ metadata := GetClientMetadata()
4141+4242+ // Split space-separated scopes from metadata into slice
4343+ scopes := strings.Fields(metadata.Scope)
4444+4545+ return oauth.NewPublicConfig(
4646+ metadata.ClientID,
4747+ metadata.RedirectURIs[0],
4848+ scopes,
4949+ )
5050+}
+183-310
internal/auth/oauth.go
···55 "context"
66 "encoding/json"
77 "fmt"
88- "io"
99- "net"
88+ "log/slog"
109 "net/http"
1110 "net/url"
1211 "strings"
1312 "time"
14131515- "github.com/bluesky-social/indigo/atproto/syntax"
1616- "github.com/golang-jwt/jwt/v5"
1717- oauth "github.com/haileyok/atproto-oauth-golang"
1818- oauth_helpers "github.com/haileyok/atproto-oauth-golang/helpers"
1919- "github.com/lestrrat-go/jwx/v2/jwk"
1414+ "github.com/bluesky-social/indigo/atproto/auth/oauth"
2015 "github.com/pkg/browser"
2116 "tangled.sh/evan.jarrett.net/blup/internal/config"
2217)
23182419type OAuthFlow struct {
2525- client *oauth.Client
2020+ app *oauth.ClientApp
2621 cfg *config.Config
2727- jwk jwk.Key
2828- server *http.Server
2929- authSuccess chan *StoredTokens
2222+ store *KeyringAuthStore
2323+ authSuccess chan *oauth.ClientSessionData
3024 authError chan error
3125 savedState string
3232- issuer string
3333- nonce string
3434- verifier string
3526}
36273728func NewOAuthFlow(cfg *config.Config) (*OAuthFlow, error) {
3838- // Get or generate JWKS
3939- key, err := GetStoredJWKS()
4040- if err != nil {
4141- // Generate new key if none exists
4242- var prefix *string
4343- key, err = oauth_helpers.GenerateKey(prefix)
4444- if err != nil {
4545- return nil, fmt.Errorf("failed to generate key: %w", err)
4646- }
4747-4848- // Store the new key
4949- if err := StoreJWKS(key); err != nil {
5050- return nil, fmt.Errorf("failed to store JWKS: %w", err)
5151- }
5252- }
2929+ store := NewKeyringAuthStore()
3030+ clientConfig := GetClientConfig()
53315454- clientMetadata := GetClientMetadata()
3232+ // Debug: show what we're requesting
3333+ metadata := GetClientMetadata()
3434+ slog.Debug("OAuth config",
3535+ "client_id", clientConfig.ClientID,
3636+ "callback_url", clientConfig.CallbackURL,
3737+ "scopes", clientConfig.Scopes,
3838+ "scope_raw", metadata.Scope,
3939+ )
55405656- // Create OAuth client
5757- clientConfig := oauth.ClientArgs{
5858- ClientJwk: key,
5959- ClientId: clientMetadata.ClientID,
6060- RedirectUri: clientMetadata.RedirectURIs[0],
6161- }
6262-6363- client, err := oauth.NewClient(clientConfig)
6464- if err != nil {
6565- return nil, fmt.Errorf("failed to create OAuth client: %w", err)
6666- }
4141+ app := oauth.NewClientApp(&clientConfig, store)
67426843 return &OAuthFlow{
6969- client: client,
4444+ app: app,
7045 cfg: cfg,
7171- jwk: key,
7272- authSuccess: make(chan *StoredTokens, 1),
4646+ store: store,
4747+ authSuccess: make(chan *oauth.ClientSessionData, 1),
7348 authError: make(chan error, 1),
7449 }, nil
7550}
76517777-func (f *OAuthFlow) Authenticate() (*StoredTokens, error) {
5252+func (f *OAuthFlow) Authenticate() (*oauth.ClientSessionData, error) {
7853 ctx := context.Background()
7979- var authServer, did, pdsHost string
8080- var err error
8181-8282- // Initialize authorization
8354 cfg := f.cfg
84558585- if cfg.Handle != "" {
8686- did, err = ResolveHandle(ctx, cfg.Handle)
8787- if err != nil {
8888- return nil, err
8989- }
9090- pdsHost, err = ResolveService(ctx, did)
9191- if err != nil {
9292- return nil, err
9393- }
9494- authServer, err = f.client.ResolvePdsAuthServer(ctx, pdsHost)
9595- if err != nil {
9696- return nil, err
9797- }
9898- cfg.PDSHost = pdsHost
9999- cfg.AuthserverIss = authServer
100100- config.SaveConfig(cfg)
101101-102102- } else {
103103- authServer = cfg.AuthserverIss
5656+ if cfg.Handle == "" {
5757+ return nil, fmt.Errorf("handle is required")
10458 }
10559106106- meta, err := f.client.FetchAuthServerMetadata(ctx, authServer)
6060+ // Start the OAuth flow - this handles handle resolution, PAR, etc.
6161+ // Note: StartAuthFlow internally calls SaveAuthRequestInfo which stores the state
6262+ redirectURL, err := f.app.StartAuthFlow(ctx, cfg.Handle)
10763 if err != nil {
108108- return nil, err
6464+ return nil, fmt.Errorf("failed to start auth flow: %w", err)
10965 }
11066111111- clientMetadata := GetClientMetadata()
112112-113113- parResp, err := f.client.SendParAuthRequest(ctx, authServer, meta, cfg.Handle, clientMetadata.Scope, f.jwk)
6767+ // Get the state from our store (saved by SaveAuthRequestInfo during StartAuthFlow)
6868+ f.savedState, err = f.store.GetPendingAuthState()
11469 if err != nil {
115115- return nil, err
7070+ return nil, fmt.Errorf("failed to get auth state: %w", err)
11671 }
117117-118118- f.savedState = parResp.State
119119- f.issuer = meta.Issuer
120120- f.verifier = parResp.PkceVerifier
121121- f.nonce = parResp.DpopAuthserverNonce
122122-123123- authUrl, _ := url.Parse(meta.AuthorizationEndpoint)
124124- authUrl.RawQuery = fmt.Sprintf("client_id=%s&request_uri=%s", url.QueryEscape(clientMetadata.ClientID), parResp.RequestUri)
7272+ slog.Debug("OAuth state retrieved", "state", f.savedState)
1257312674 go f.waitForAuthSSE()
1277512876 // Open browser to authorization URL
12977 fmt.Printf("Opening browser for authentication...\n")
130130- if err := browser.OpenURL(authUrl.String()); err != nil {
7878+ if err := browser.OpenURL(redirectURL); err != nil {
13179 fmt.Printf("Failed to open browser automatically.\n")
132132- fmt.Printf("Please open this URL manually:\n%s\n", authUrl)
8080+ fmt.Printf("Please open this URL manually:\n%s\n", redirectURL)
13381 }
1348213583 // Wait for callback
13684 fmt.Println("Waiting for authentication...")
1378513886 select {
139139- case tokens := <-f.authSuccess:
8787+ case sess := <-f.authSuccess:
14088 fmt.Println("Authentication successful!")
141141- return tokens, nil
8989+9090+ // Save as current session
9191+ if err := f.store.SetCurrentSession(ctx, sess); err != nil {
9292+ return nil, fmt.Errorf("failed to save current session: %w", err)
9393+ }
9494+9595+ // Update config with PDS host
9696+ cfg.PDSHost = sess.HostURL
9797+ cfg.AuthserverIss = sess.AuthServerURL
9898+ config.SaveConfig(cfg)
9999+100100+ return sess, nil
142101 case err := <-f.authError:
143102 return nil, fmt.Errorf("authentication failed: %w", err)
144103 case <-time.After(5 * time.Minute):
···146105 }
147106}
148107149149-func (f *OAuthFlow) waitForAuthSSE() (string, error) {
108108+func (f *OAuthFlow) waitForAuthSSE() {
150109 clientMetadata := GetClientMetadata()
151151- req, err := http.NewRequest("GET",
152152- fmt.Sprintf("%s/oauth/events?session=%s", clientMetadata.ClientURI, f.savedState),
153153- nil)
154154- if err != nil {
155155- f.authError <- err
156156- }
110110+ sseURL := fmt.Sprintf("%s/oauth/events?session=%s", clientMetadata.ClientURI, f.savedState)
111111+ slog.Debug("SSE connecting", "url", sseURL)
157112158158- req.Header.Set("Accept", "text/event-stream")
159159- req.Header.Set("Cache-Control", "no-cache")
113113+ req, err := http.NewRequest("GET", sseURL, nil)
114114+ if err != nil {
115115+ f.authError <- err
116116+ return
117117+ }
160118161161- client := &http.Client{Timeout: 5 * time.Minute}
162162- resp, err := client.Do(req)
163163- if err != nil {
164164- f.authError <- err
165165- }
166166- defer resp.Body.Close()
119119+ req.Header.Set("Accept", "text/event-stream")
120120+ req.Header.Set("Cache-Control", "no-cache")
167121168168- reader := bufio.NewReader(resp.Body)
122122+ client := &http.Client{Timeout: 5 * time.Minute}
123123+ resp, err := client.Do(req)
124124+ if err != nil {
125125+ slog.Debug("SSE connection error", "error", err)
126126+ f.authError <- err
127127+ return
128128+ }
129129+ defer resp.Body.Close()
130130+131131+ slog.Debug("SSE connected", "status", resp.StatusCode)
132132+ reader := bufio.NewReader(resp.Body)
133133+134134+ for {
135135+ line, err := reader.ReadString('\n')
136136+ if err != nil {
137137+ slog.Debug("SSE read error", "error", err)
138138+ f.authError <- err
139139+ return
140140+ }
169141170170- for {
171171- line, err := reader.ReadString('\n')
172172- if err != nil {
173173- f.authError <- err
174174- }
142142+ line = strings.TrimSpace(line)
143143+ if line != "" && !strings.HasPrefix(line, ":") {
144144+ slog.Debug("SSE received", "line", line)
145145+ }
175146176176- if strings.HasPrefix(line, "event: auth-complete") {
177177- // Read data line
178178- dataLine, _ := reader.ReadString('\n')
179179- if strings.HasPrefix(dataLine, "data: ") {
180180- data := strings.TrimPrefix(dataLine, "data: ")
181181- var authData struct {
182182- Code string `json:"code"`
183183- Iss string `json:"iss"`
147147+ if strings.HasPrefix(line, "event: auth-complete") {
148148+ // Read data line
149149+ dataLine, _ := reader.ReadString('\n')
150150+ slog.Debug("SSE auth data", "data", dataLine)
151151+ if after, ok := strings.CutPrefix(dataLine, "data: "); ok {
152152+ data := after
153153+ var authData struct {
154154+ Code string `json:"code"`
155155+ Iss string `json:"iss"`
184156 State string `json:"state"`
185185- }
186186- json.Unmarshal([]byte(data), &authData)
187187- f.handleCallback(req.Context(), authData.Code, authData.Iss, authData.State)
188188- }
189189- }
190190- }
157157+ }
158158+ if err := json.Unmarshal([]byte(data), &authData); err != nil {
159159+ f.authError <- fmt.Errorf("failed to parse auth data: %w", err)
160160+ return
161161+ }
162162+ slog.Debug("SSE auth complete", "iss", authData.Iss, "state", authData.State)
163163+ f.handleCallback(req.Context(), authData.Code, authData.Iss, authData.State)
164164+ return
165165+ }
166166+ }
167167+ }
191168}
192169193170func (f *OAuthFlow) handleCallback(ctx context.Context, code, iss, state string) {
194194- // Get authorization code from query params
195171 if state == "" || iss == "" || code == "" {
196172 f.authError <- fmt.Errorf("request missing needed parameters")
173173+ return
197174 }
198175199199- if state != f.savedState {
200200- f.authError <- fmt.Errorf("session state does not match response state")
201201- }
202202-203203- if iss != f.issuer {
204204- f.authError <- fmt.Errorf("incoming iss did not match authserver iss")
205205- }
176176+ // Build URL values for ProcessCallback
177177+ params := url.Values{}
178178+ params.Set("code", code)
179179+ params.Set("iss", iss)
180180+ params.Set("state", state)
206181207207- initialTokenResp, err := f.client.InitialTokenRequest(ctx, code, iss, f.verifier, f.nonce, f.jwk)
182182+ // ProcessCallback handles token exchange and session creation
183183+ sess, err := f.app.ProcessCallback(ctx, params)
208184 if err != nil {
209209- f.authError <- err
185185+ f.authError <- fmt.Errorf("failed to process callback: %w", err)
186186+ return
210187 }
211188212212- if initialTokenResp.Scope != GetClientMetadata().Scope {
213213- f.authError <- fmt.Errorf("did not receive correct scopes from token request")
214214- }
215215-216216- // Store tokens
217217- tokens, err := StoreTokensFromLibrary(initialTokenResp)
218218- if err != nil {
219219- f.authError <- fmt.Errorf("failed to store tokens: %w", err)
220220- }
221221-222222- // Send code through channel
223223- f.authSuccess <- tokens
189189+ f.authSuccess <- sess
224190}
225191226226-// RefreshTokens refreshes the access token using the stored refresh token
227227-func RefreshTokens(cfg *config.Config) (*StoredTokens, error) {
228228- // Check if we have valid tokens
229229- tokens, _ := GetStoredTokens()
192192+// GetSession retrieves the current session, refreshing tokens if needed
193193+func GetSession(cfg *config.Config) (*oauth.ClientSession, error) {
194194+ ctx := context.Background()
195195+ store := NewKeyringAuthStore()
230196231231- flow, err := NewOAuthFlow(cfg)
197197+ // Check for current session
198198+ sessData, err := store.GetCurrentSession(ctx)
232199 if err != nil {
233233- return nil, err
234234- }
235235- var newTokens *oauth.TokenResponse
236236-237237- if tokens == nil {
238238- return flow.Authenticate()
239239- }
240240- if tokens.NeedsRefresh() {
241241- ctx := context.Background()
242242- newTokens, err = flow.client.RefreshTokenRequest(ctx, tokens.RefreshToken, cfg.AuthserverIss, tokens.DpopAuthserverNonce, flow.jwk)
243243-244244- if err != nil {
245245- return nil, fmt.Errorf("failed to refresh tokens: %w", err)
246246- }
247247- return StoreTokensFromLibrary(newTokens)
200200+ return nil, fmt.Errorf("no active session")
248201 }
249202250250- return tokens, nil
251251-}
203203+ clientConfig := GetClientConfig()
204204+ app := oauth.NewClientApp(&clientConfig, store)
252205253253-func GetPDSFromToken(accessToken string) (string, error) {
254254- // Parse without verification (we just need to read claims)
255255- token, _, err := new(jwt.Parser).ParseUnverified(accessToken, jwt.MapClaims{})
206206+ // Resume the session
207207+ sess, err := app.ResumeSession(ctx, sessData.AccountDID, sessData.SessionID)
256208 if err != nil {
257257- return "", fmt.Errorf("failed to parse JWT: %w", err)
209209+ return nil, fmt.Errorf("failed to resume session: %w", err)
258210 }
259211260260- claims, ok := token.Claims.(jwt.MapClaims)
261261- if !ok {
262262- return "", fmt.Errorf("failed to get claims")
263263- }
264264-265265- // Extract the 'aud' field
266266- aud, ok := claims["aud"].(string)
267267- if !ok {
268268- return "", fmt.Errorf("'aud' field not found or not a string")
269269- }
270270-271271- // Convert did:web:... to https://...
272272- if after, ok0 :=strings.CutPrefix(aud, "did:web:"); ok0 {
273273- pdsHost := after
274274- return "https://" + pdsHost, nil
275275- }
276276-277277- return "", fmt.Errorf("unexpected 'aud' format: %s", aud)
212212+ return sess, nil
278213}
279214280280-func ResolveHandle(ctx context.Context, handle string) (string, error) {
281281- var did string
215215+// RefreshTokens refreshes the access token if needed and returns the session
216216+func RefreshTokens(cfg *config.Config) (*oauth.ClientSession, error) {
217217+ ctx := context.Background()
218218+ store := NewKeyringAuthStore()
282219283283- _, err := syntax.ParseHandle(handle)
284284- if err != nil {
285285- return "", err
286286- }
287287-288288- recs, err := net.LookupTXT(fmt.Sprintf("_atproto.%s", handle))
289289- if err == nil {
290290- for _, rec := range recs {
291291- if strings.HasPrefix(rec, "did=") {
292292- did = strings.Split(rec, "did=")[1]
293293- break
294294- }
295295- }
296296- }
297297-298298- // Try external DNS if system DNS failed or returned no DID
299299- if did == "" {
300300- externalDNS := []string{"8.8.8.8"}
301301- for _, dnsServer := range externalDNS {
302302- externalDID, err := lookupTXTExternal(fmt.Sprintf("_atproto.%s", handle), dnsServer)
303303- if err == nil && externalDID != "" {
304304- did = externalDID
305305- break
306306- }
307307- }
308308- }
309309-310310- if did == "" {
311311- req, err := http.NewRequestWithContext(
312312- ctx,
313313- "GET",
314314- fmt.Sprintf("https://%s/.well-known/atproto-did", handle),
315315- nil,
316316- )
220220+ // Check for legacy tokens and migrate
221221+ if HasLegacyTokens() {
222222+ DeleteLegacyTokens()
223223+ fmt.Println("Your stored credentials are from an older version.")
224224+ fmt.Println("Please re-authenticate.")
225225+ // Trigger new auth flow
226226+ flow, err := NewOAuthFlow(cfg)
317227 if err != nil {
318318- return "", err
228228+ return nil, err
319229 }
320320-321321- resp, err := http.DefaultClient.Do(req)
230230+ sess, err := flow.Authenticate()
322231 if err != nil {
323323- return "", err
232232+ return nil, err
324233 }
325325- defer resp.Body.Close()
326326-327327- if resp.StatusCode != http.StatusOK {
328328- io.Copy(io.Discard, resp.Body)
329329- return "", fmt.Errorf("unable to resolve handle")
330330- }
234234+ // Resume the session to return a ClientSession
235235+ clientConfig := GetClientConfig()
236236+ app := oauth.NewClientApp(&clientConfig, store)
237237+ return app.ResumeSession(ctx, sess.AccountDID, sess.SessionID)
238238+ }
331239332332- b, err := io.ReadAll(resp.Body)
240240+ // Check for current session
241241+ sessData, err := store.GetCurrentSession(ctx)
242242+ if err != nil {
243243+ // No session, need to authenticate
244244+ flow, err := NewOAuthFlow(cfg)
333245 if err != nil {
334334- return "", err
246246+ return nil, err
335247 }
336336-337337- maybeDid := string(b)
338338-339339- if _, err := syntax.ParseDID(maybeDid); err != nil {
340340- return "", fmt.Errorf("unable to resolve handle")
248248+ sess, err := flow.Authenticate()
249249+ if err != nil {
250250+ return nil, err
341251 }
342342-343343- did = maybeDid
344344- }
345345-346346- return did, nil
347347-}
348348-349349-func lookupTXTExternal(domain, dnsServer string) (string, error) {
350350- resolver := &net.Resolver{
351351- PreferGo: true,
352352- Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
353353- d := net.Dialer{
354354- Timeout: 5 * time.Second,
355355- }
356356- return d.DialContext(ctx, network, dnsServer+":53")
357357- },
252252+ // Resume the session to return a ClientSession
253253+ clientConfig := GetClientConfig()
254254+ app := oauth.NewClientApp(&clientConfig, store)
255255+ return app.ResumeSession(ctx, sess.AccountDID, sess.SessionID)
358256 }
359257360360- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
361361- defer cancel()
258258+ clientConfig := GetClientConfig()
259259+ app := oauth.NewClientApp(&clientConfig, store)
362260363363- recs, err := resolver.LookupTXT(ctx, domain)
261261+ // Resume the session - this will auto-refresh tokens on 401
262262+ sess, err := app.ResumeSession(ctx, sessData.AccountDID, sessData.SessionID)
364263 if err != nil {
365365- return "", err
366366- }
367367-368368- for _, rec := range recs {
369369- if strings.HasPrefix(rec, "did=") {
370370- return strings.Split(rec, "did=")[1], nil
264264+ // Session invalid, need to re-authenticate
265265+ flow, err := NewOAuthFlow(cfg)
266266+ if err != nil {
267267+ return nil, err
268268+ }
269269+ newSess, err := flow.Authenticate()
270270+ if err != nil {
271271+ return nil, err
371272 }
273273+ return app.ResumeSession(ctx, newSess.AccountDID, newSess.SessionID)
372274 }
373275374374- return "", nil
276276+ return sess, nil
375277}
376278377377-func ResolveService(ctx context.Context, did string) (string, error) {
378378- type Identity struct {
379379- Service []struct {
380380- ID string `json:"id"`
381381- Type string `json:"type"`
382382- ServiceEndpoint string `json:"serviceEndpoint"`
383383- } `json:"service"`
384384- }
279279+// Logout revokes tokens and clears the session
280280+func Logout(cfg *config.Config) error {
281281+ ctx := context.Background()
282282+ store := NewKeyringAuthStore()
385283386386- var ustr string
387387- if strings.HasPrefix(did, "did:plc:") {
388388- ustr = fmt.Sprintf("https://plc.directory/%s", did)
389389- } else if strings.HasPrefix(did, "did:web:") {
390390- ustr = fmt.Sprintf("https://%s/.well-known/did.json", strings.TrimPrefix(did, "did:web:"))
391391- } else {
392392- return "", fmt.Errorf("did was not a supported did type")
393393- }
394394-395395- req, err := http.NewRequestWithContext(ctx, "GET", ustr, nil)
284284+ sessData, err := store.GetCurrentSession(ctx)
396285 if err != nil {
397397- return "", err
286286+ // No session to logout
287287+ return nil
398288 }
399289400400- resp, err := http.DefaultClient.Do(req)
401401- if err != nil {
402402- return "", err
403403- }
404404- defer resp.Body.Close()
290290+ clientConfig := GetClientConfig()
291291+ app := oauth.NewClientApp(&clientConfig, store)
405292406406- if resp.StatusCode != 200 {
407407- io.Copy(io.Discard, resp.Body)
408408- return "", fmt.Errorf("could not find identity in plc registry")
409409- }
410410-411411- var identity Identity
412412- if err := json.NewDecoder(resp.Body).Decode(&identity); err != nil {
413413- return "", err
414414- }
415415-416416- var service string
417417- for _, svc := range identity.Service {
418418- if svc.ID == "#atproto_pds" {
419419- service = svc.ServiceEndpoint
420420- }
293293+ // Logout revokes tokens and deletes session
294294+ if err := app.Logout(ctx, sessData.AccountDID, sessData.SessionID); err != nil {
295295+ // Log but don't fail - just clear local state
296296+ fmt.Printf("Warning: failed to revoke tokens: %v\n", err)
421297 }
422298423423- if service == "" {
424424- return "", fmt.Errorf("could not find atproto_pds service in identity services")
425425- }
426426-427427- return service, nil
299299+ // Clear current session reference
300300+ return store.ClearCurrentSession()
428301}
+116-67
internal/auth/storage.go
···11package auth
2233import (
44+ "context"
45 "encoding/json"
55- "time"
66+ "fmt"
6777- oauth "github.com/haileyok/atproto-oauth-golang"
88- "github.com/lestrrat-go/jwx/v2/jwk"
88+ "github.com/bluesky-social/indigo/atproto/auth/oauth"
99+ "github.com/bluesky-social/indigo/atproto/syntax"
910 "github.com/zalando/go-keyring"
1011)
11121213const (
1313- keyringService = "blup"
1414- keyringUser = "oauth-tokens"
1515- keyringJWKS = "oauth-jwks" // Separate key for JWKS
1414+ keyringService = "blup"
1515+ currentSessionKey = "current-session"
1616+ sessionKeyPrefix = "session:"
1717+ authRequestPrefix = "auth-request:"
1818+ pendingAuthStateKey = "pending-auth-state"
1919+ legacyTokensKey = "oauth-tokens"
2020+ legacyJWKSKey = "oauth-jwks"
1621)
17221818-type StoredTokens struct {
1919- DpopAuthserverNonce string `json:"nonce"`
2020- AccessToken string `json:"access_token"`
2121- RefreshToken string `json:"refresh_token"`
2222- ExpiresAt int64 `json:"expires_at"`
2323- Scope string `json:"scope"`
2424- Sub string `json:"sub"`
2525- TokenType string `json:"token_type"`
2323+// KeyringAuthStore implements oauth.ClientAuthStore using the system keyring
2424+type KeyringAuthStore struct{}
2525+2626+func NewKeyringAuthStore() *KeyringAuthStore {
2727+ return &KeyringAuthStore{}
2628}
27292828-// StoreJWKS stores the private key separately
2929-func StoreJWKS(key jwk.Key) error {
3030- // Convert the key to JSON
3131- data, err := json.Marshal(key)
3232- if err != nil {
3333- return err
3434- }
3535-3636- return keyring.Set(keyringService, keyringJWKS, string(data))
3030+// sessionKey creates the keyring key for a session
3131+func sessionKey(did syntax.DID, sessionID string) string {
3232+ return fmt.Sprintf("%s%s:%s", sessionKeyPrefix, did.String(), sessionID)
3733}
38343939-// GetStoredJWKS retrieves the stored private key
4040-func GetStoredJWKS() (jwk.Key, error) {
4141- data, err := keyring.Get(keyringService, keyringJWKS)
3535+// GetSession retrieves a session from the keyring
3636+func (s *KeyringAuthStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) {
3737+ data, err := keyring.Get(keyringService, sessionKey(did, sessionID))
4238 if err != nil {
4339 return nil, err
4440 }
45414646- // Parse the key from JSON
4747- key, err := jwk.ParseKey([]byte(data))
4848- if err != nil {
4242+ var sess oauth.ClientSessionData
4343+ if err := json.Unmarshal([]byte(data), &sess); err != nil {
4944 return nil, err
5045 }
51465252- return key, nil
4747+ return &sess, nil
5348}
54495555-func StoreTokensFromLibrary(tokens *oauth.TokenResponse) (*StoredTokens, error) {
5656- stored := &StoredTokens{
5757- DpopAuthserverNonce: tokens.DpopAuthserverNonce,
5858- AccessToken: tokens.AccessToken,
5959- RefreshToken: tokens.RefreshToken,
6060- ExpiresAt: time.Now().Unix() + int64(tokens.ExpiresIn),
6161- Scope: tokens.Scope,
6262- Sub: tokens.Sub,
6363- TokenType: tokens.TokenType,
5050+// SaveSession stores a session in the keyring
5151+func (s *KeyringAuthStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error {
5252+ data, err := json.Marshal(sess)
5353+ if err != nil {
5454+ return err
6455 }
65566666- data, err := json.Marshal(stored)
5757+ return keyring.Set(keyringService, sessionKey(sess.AccountDID, sess.SessionID), string(data))
5858+}
5959+6060+// DeleteSession removes a session from the keyring
6161+func (s *KeyringAuthStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error {
6262+ return keyring.Delete(keyringService, sessionKey(did, sessionID))
6363+}
6464+6565+// authRequestKey creates the keyring key for an auth request
6666+func authRequestKey(state string) string {
6767+ return fmt.Sprintf("%s%s", authRequestPrefix, state)
6868+}
6969+7070+// GetAuthRequestInfo retrieves pending auth request info
7171+func (s *KeyringAuthStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) {
7272+ data, err := keyring.Get(keyringService, authRequestKey(state))
6773 if err != nil {
6874 return nil, err
6975 }
70767171- if keyring.Set(keyringService, keyringUser, string(data)) != nil {
7777+ var info oauth.AuthRequestData
7878+ if err := json.Unmarshal([]byte(data), &info); err != nil {
7279 return nil, err
7380 }
74817575- return stored, nil
8282+ return &info, nil
7683}
77847878-func StoreTokens(tokens *StoredTokens) (*StoredTokens, error) {
7979-8080- data, err := json.Marshal(tokens)
8585+// SaveAuthRequestInfo stores pending auth request info
8686+func (s *KeyringAuthStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error {
8787+ data, err := json.Marshal(info)
8188 if err != nil {
8282- return nil, err
8989+ return err
8390 }
84918585- if keyring.Set(keyringService, keyringUser, string(data)) != nil {
8686- return nil, err
9292+ // Save the auth request data
9393+ if err := keyring.Set(keyringService, authRequestKey(info.State), string(data)); err != nil {
9494+ return err
8795 }
88968989- return tokens, nil
9797+ // Also save the state as the current pending auth (for SSE correlation)
9898+ return keyring.Set(keyringService, pendingAuthStateKey, info.State)
9999+}
100100+101101+// GetPendingAuthState returns the state of the current pending auth request
102102+func (s *KeyringAuthStore) GetPendingAuthState() (string, error) {
103103+ return keyring.Get(keyringService, pendingAuthStateKey)
90104}
911059292-func GetStoredTokens() (*StoredTokens, error) {
9393- data, err := keyring.Get(keyringService, keyringUser)
106106+// ClearPendingAuthState removes the pending auth state
107107+func (s *KeyringAuthStore) ClearPendingAuthState() error {
108108+ return keyring.Delete(keyringService, pendingAuthStateKey)
109109+}
110110+111111+// DeleteAuthRequestInfo removes pending auth request info
112112+func (s *KeyringAuthStore) DeleteAuthRequestInfo(ctx context.Context, state string) error {
113113+ return keyring.Delete(keyringService, authRequestKey(state))
114114+}
115115+116116+// CurrentSessionRef stores reference to the current active session
117117+type CurrentSessionRef struct {
118118+ DID string `json:"did"`
119119+ SessionID string `json:"session_id"`
120120+}
121121+122122+// GetCurrentSession retrieves the current active session for the CLI
123123+func (s *KeyringAuthStore) GetCurrentSession(ctx context.Context) (*oauth.ClientSessionData, error) {
124124+ refData, err := keyring.Get(keyringService, currentSessionKey)
94125 if err != nil {
95126 return nil, err
96127 }
971289898- var tokens StoredTokens
9999- if err := json.Unmarshal([]byte(data), &tokens); err != nil {
129129+ var ref CurrentSessionRef
130130+ if err := json.Unmarshal([]byte(refData), &ref); err != nil {
131131+ return nil, err
132132+ }
133133+134134+ did, err := syntax.ParseDID(ref.DID)
135135+ if err != nil {
100136 return nil, err
101137 }
102138103103- return &tokens, nil
139139+ return s.GetSession(ctx, did, ref.SessionID)
104140}
105141106106-func DeleteStoredTokens() error {
107107- return keyring.Delete(keyringService, keyringUser)
142142+// SetCurrentSession sets the current active session reference
143143+func (s *KeyringAuthStore) SetCurrentSession(ctx context.Context, sess *oauth.ClientSessionData) error {
144144+ ref := CurrentSessionRef{
145145+ DID: sess.AccountDID.String(),
146146+ SessionID: sess.SessionID,
147147+ }
148148+149149+ data, err := json.Marshal(ref)
150150+ if err != nil {
151151+ return err
152152+ }
153153+154154+ return keyring.Set(keyringService, currentSessionKey, string(data))
108155}
109156110110-func DeleteStoredJWKS() error {
111111- return keyring.Delete(keyringService, keyringJWKS)
157157+// ClearCurrentSession removes the current session reference
158158+func (s *KeyringAuthStore) ClearCurrentSession() error {
159159+ return keyring.Delete(keyringService, currentSessionKey)
112160}
113161114114-// DeleteAll removes both tokens and JWKS
115115-func DeleteAll() error {
116116- if err := DeleteStoredTokens(); err != nil {
117117- return err
118118- }
119119- return DeleteStoredJWKS()
162162+// HasLegacyTokens checks if old-format tokens exist (for migration)
163163+func HasLegacyTokens() bool {
164164+ _, err := keyring.Get(keyringService, legacyTokensKey)
165165+ return err == nil
120166}
121167122122-func (t *StoredTokens) NeedsRefresh() bool {
123123- sevenDaysFromNow := time.Now().Unix() + (7 * 24 * 60 * 60)
124124- return t.ExpiresAt <= sevenDaysFromNow
168168+// DeleteLegacyTokens removes old-format tokens and JWKS
169169+func DeleteLegacyTokens() error {
170170+ // Ignore errors - keys might not exist
171171+ keyring.Delete(keyringService, legacyTokensKey)
172172+ keyring.Delete(keyringService, legacyJWKSKey)
173173+ return nil
125174}
+12
internal/clipboard/clipboard.go
···11+// Package clipboard provides native clipboard access for Wayland.
22+package clipboard
33+44+// CopyText copies the given text to the system clipboard.
55+// On Wayland, this uses the wlr-data-control protocol directly,
66+// without requiring external tools like wl-copy.
77+//
88+// The implementation forks a background process to serve paste
99+// requests until another application takes clipboard ownership.
1010+func CopyText(text string) error {
1111+ return copyTextPlatform(text)
1212+}