Go boilerplate library for building atproto apps
atproto go
1
fork

Configure Feed

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

at main 188 lines 5.6 kB view raw
1package atp 2 3import ( 4 "context" 5 "fmt" 6 "net/http" 7 "net/url" 8 "strings" 9 10 "github.com/bluesky-social/indigo/atproto/auth/oauth" 11 "github.com/bluesky-social/indigo/atproto/syntax" 12 "github.com/pkg/browser" 13) 14 15// OAuthConfig configures an [OAuthApp]. 16type OAuthConfig struct { 17 // ClientID is the OAuth client ID (a URL). Empty string or a localhost/127.0.0.1 18 // prefix triggers development mode (localhost OAuth with automatic PKCE). 19 ClientID string 20 21 // RedirectURI is the fully-qualified OAuth callback URL. 22 RedirectURI string 23 24 // Scopes for the OAuth grant. Use [ScopesForCollections] to build these 25 // from collection NSIDs. Must include "atproto". 26 Scopes []string 27 28 // Store persists OAuth sessions and auth request state. If nil, an 29 // in-memory store is used (sessions lost on restart). 30 Store oauth.ClientAuthStore 31 32 // AppName is an optional human-readable name included in client metadata. 33 AppName string 34} 35 36// SessionInfo is returned after a successful OAuth callback. 37type SessionInfo struct { 38 DID syntax.DID 39 SessionID string 40 Scopes []string 41} 42 43// OAuthApp manages AT Protocol OAuth flows. 44type OAuthApp struct { 45 app *oauth.ClientApp 46 appName string 47} 48 49// NewOAuthApp creates a new OAuth application from the given config. 50func NewOAuthApp(cfg OAuthConfig) (*OAuthApp, error) { 51 var config oauth.ClientConfig 52 53 if cfg.ClientID == "" || 54 strings.HasPrefix(cfg.ClientID, "http://localhost") || 55 strings.HasPrefix(cfg.ClientID, "http://127.0.0.1") { 56 config = oauth.NewLocalhostConfig(cfg.RedirectURI, cfg.Scopes) 57 } else { 58 config = oauth.NewPublicConfig(cfg.ClientID, cfg.RedirectURI, cfg.Scopes) 59 } 60 61 store := cfg.Store 62 if store == nil { 63 store = oauth.NewMemStore() 64 } 65 66 app := oauth.NewClientApp(&config, store) 67 68 return &OAuthApp{ 69 app: app, 70 appName: cfg.AppName, 71 }, nil 72} 73 74// StartLogin begins the web OAuth flow. Returns the authorization URL to 75// redirect the user to. Use [HandleCallback] when the user returns. 76func (a *OAuthApp) StartLogin(ctx context.Context, handle string) (string, error) { 77 authURL, err := a.app.StartAuthFlow(ctx, handle) 78 if err != nil { 79 return "", fmt.Errorf("start auth flow: %w", err) 80 } 81 return authURL, nil 82} 83 84// HandleCallback processes the OAuth callback after user authorization. 85// Pass r.URL.Query() from the callback request. 86func (a *OAuthApp) HandleCallback(ctx context.Context, params url.Values) (*SessionInfo, error) { 87 sessData, err := a.app.ProcessCallback(ctx, params) 88 if err != nil { 89 return nil, fmt.Errorf("process callback: %w", err) 90 } 91 return &SessionInfo{ 92 DID: sessData.AccountDID, 93 SessionID: sessData.SessionID, 94 Scopes: sessData.Scopes, 95 }, nil 96} 97 98// LoginCLI runs a complete loopback OAuth flow for CLI applications. 99// It opens the user's browser, starts a temporary HTTP server to receive the 100// callback, and blocks until authentication completes. 101// TODO: should this be part of the library? probably not? (removeds `browser` dep) 102func (a *OAuthApp) LoginCLI(ctx context.Context, handle string) (*SessionInfo, error) { 103 authURL, err := a.app.StartAuthFlow(ctx, handle) 104 if err != nil { 105 return nil, fmt.Errorf("start auth flow: %w", err) 106 } 107 108 // Parse callback path and port from the configured redirect URI 109 redirectURL, err := url.Parse(a.app.Config.CallbackURL) 110 if err != nil { 111 return nil, fmt.Errorf("parse redirect URI: %w", err) 112 } 113 114 if err := browser.OpenURL(authURL); err != nil { 115 fmt.Printf("Open this URL in your browser:\n%s\n", authURL) 116 } 117 118 type result struct { 119 info *SessionInfo 120 err error 121 } 122 ch := make(chan result, 1) 123 124 mux := http.NewServeMux() 125 mux.HandleFunc(redirectURL.Path, func(w http.ResponseWriter, r *http.Request) { 126 sessData, err := a.app.ProcessCallback(r.Context(), r.URL.Query()) 127 if err != nil { 128 w.WriteHeader(http.StatusInternalServerError) 129 fmt.Fprint(w, "Authentication failed") 130 ch <- result{err: fmt.Errorf("process callback: %w", err)} 131 return 132 } 133 134 w.Header().Set("Content-Type", "text/html") 135 fmt.Fprint(w, "<html><body><h1>Authenticated!</h1><p>You can close this tab.</p></body></html>") 136 ch <- result{info: &SessionInfo{ 137 DID: sessData.AccountDID, 138 SessionID: sessData.SessionID, 139 Scopes: sessData.Scopes, 140 }} 141 }) 142 143 addr := redirectURL.Host 144 server := &http.Server{Addr: addr, Handler: mux} 145 go server.ListenAndServe() 146 147 res := <-ch 148 server.Close() 149 150 if res.err != nil { 151 return nil, res.err 152 } 153 return res.info, nil 154} 155 156// ResumeSession restores an existing OAuth session and returns a [Client] 157// ready for PDS CRUD operations. 158func (a *OAuthApp) ResumeSession(ctx context.Context, did syntax.DID, sessionID string) (*Client, error) { 159 session, err := a.app.ResumeSession(ctx, did, sessionID) 160 if err != nil { 161 return nil, fmt.Errorf("%w: %w", ErrSessionExpired, err) 162 } 163 return NewClient(session.APIClient(), did), nil 164} 165 166// DeleteSession removes an OAuth session (for logout). 167func (a *OAuthApp) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { 168 return a.app.Store.DeleteSession(ctx, did, sessionID) 169} 170 171// Logout revokes the OAuth tokens and deletes the session. 172func (a *OAuthApp) Logout(ctx context.Context, did syntax.DID, sessionID string) error { 173 return a.app.Logout(ctx, did, sessionID) 174} 175 176// ClientMetadata returns the OAuth client metadata document. Serve this 177// at your client_id URL and at /.well-known/oauth-client-metadata. 178func (a *OAuthApp) ClientMetadata() oauth.ClientMetadata { 179 meta := a.app.Config.ClientMetadata() 180 if a.appName != "" { 181 meta.ClientName = &a.appName 182 } 183 return meta 184} 185 186func (a *OAuthApp) Store() oauth.ClientAuthStore { 187 return a.app.Store 188}