package atp import ( "context" "fmt" "net/http" "net/url" "strings" "github.com/bluesky-social/indigo/atproto/auth/oauth" "github.com/bluesky-social/indigo/atproto/syntax" "github.com/pkg/browser" ) // OAuthConfig configures an [OAuthApp]. type OAuthConfig struct { // ClientID is the OAuth client ID (a URL). Empty string or a localhost/127.0.0.1 // prefix triggers development mode (localhost OAuth with automatic PKCE). ClientID string // RedirectURI is the fully-qualified OAuth callback URL. RedirectURI string // Scopes for the OAuth grant. Use [ScopesForCollections] to build these // from collection NSIDs. Must include "atproto". Scopes []string // Store persists OAuth sessions and auth request state. If nil, an // in-memory store is used (sessions lost on restart). Store oauth.ClientAuthStore // AppName is an optional human-readable name included in client metadata. AppName string } // SessionInfo is returned after a successful OAuth callback. type SessionInfo struct { DID syntax.DID SessionID string Scopes []string } // OAuthApp manages AT Protocol OAuth flows. type OAuthApp struct { app *oauth.ClientApp appName string } // NewOAuthApp creates a new OAuth application from the given config. func NewOAuthApp(cfg OAuthConfig) (*OAuthApp, error) { var config oauth.ClientConfig if cfg.ClientID == "" || strings.HasPrefix(cfg.ClientID, "http://localhost") || strings.HasPrefix(cfg.ClientID, "http://127.0.0.1") { config = oauth.NewLocalhostConfig(cfg.RedirectURI, cfg.Scopes) } else { config = oauth.NewPublicConfig(cfg.ClientID, cfg.RedirectURI, cfg.Scopes) } store := cfg.Store if store == nil { store = oauth.NewMemStore() } app := oauth.NewClientApp(&config, store) return &OAuthApp{ app: app, appName: cfg.AppName, }, nil } // StartLogin begins the web OAuth flow. Returns the authorization URL to // redirect the user to. Use [HandleCallback] when the user returns. func (a *OAuthApp) StartLogin(ctx context.Context, handle string) (string, error) { authURL, err := a.app.StartAuthFlow(ctx, handle) if err != nil { return "", fmt.Errorf("start auth flow: %w", err) } return authURL, nil } // HandleCallback processes the OAuth callback after user authorization. // Pass r.URL.Query() from the callback request. func (a *OAuthApp) HandleCallback(ctx context.Context, params url.Values) (*SessionInfo, error) { sessData, err := a.app.ProcessCallback(ctx, params) if err != nil { return nil, fmt.Errorf("process callback: %w", err) } return &SessionInfo{ DID: sessData.AccountDID, SessionID: sessData.SessionID, Scopes: sessData.Scopes, }, nil } // LoginCLI runs a complete loopback OAuth flow for CLI applications. // It opens the user's browser, starts a temporary HTTP server to receive the // callback, and blocks until authentication completes. // TODO: should this be part of the library? probably not? (removeds `browser` dep) func (a *OAuthApp) LoginCLI(ctx context.Context, handle string) (*SessionInfo, error) { authURL, err := a.app.StartAuthFlow(ctx, handle) if err != nil { return nil, fmt.Errorf("start auth flow: %w", err) } // Parse callback path and port from the configured redirect URI redirectURL, err := url.Parse(a.app.Config.CallbackURL) if err != nil { return nil, fmt.Errorf("parse redirect URI: %w", err) } if err := browser.OpenURL(authURL); err != nil { fmt.Printf("Open this URL in your browser:\n%s\n", authURL) } type result struct { info *SessionInfo err error } ch := make(chan result, 1) mux := http.NewServeMux() mux.HandleFunc(redirectURL.Path, func(w http.ResponseWriter, r *http.Request) { sessData, err := a.app.ProcessCallback(r.Context(), r.URL.Query()) if err != nil { w.WriteHeader(http.StatusInternalServerError) fmt.Fprint(w, "Authentication failed") ch <- result{err: fmt.Errorf("process callback: %w", err)} return } w.Header().Set("Content-Type", "text/html") fmt.Fprint(w, "
You can close this tab.
") ch <- result{info: &SessionInfo{ DID: sessData.AccountDID, SessionID: sessData.SessionID, Scopes: sessData.Scopes, }} }) addr := redirectURL.Host server := &http.Server{Addr: addr, Handler: mux} go server.ListenAndServe() res := <-ch server.Close() if res.err != nil { return nil, res.err } return res.info, nil } // ResumeSession restores an existing OAuth session and returns a [Client] // ready for PDS CRUD operations. func (a *OAuthApp) ResumeSession(ctx context.Context, did syntax.DID, sessionID string) (*Client, error) { session, err := a.app.ResumeSession(ctx, did, sessionID) if err != nil { return nil, fmt.Errorf("%w: %w", ErrSessionExpired, err) } return NewClient(session.APIClient(), did), nil } // DeleteSession removes an OAuth session (for logout). func (a *OAuthApp) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { return a.app.Store.DeleteSession(ctx, did, sessionID) } // Logout revokes the OAuth tokens and deletes the session. func (a *OAuthApp) Logout(ctx context.Context, did syntax.DID, sessionID string) error { return a.app.Logout(ctx, did, sessionID) } // ClientMetadata returns the OAuth client metadata document. Serve this // at your client_id URL and at /.well-known/oauth-client-metadata. func (a *OAuthApp) ClientMetadata() oauth.ClientMetadata { meta := a.app.Config.ClientMetadata() if a.appName != "" { meta.ClientName = &a.appName } return meta } func (a *OAuthApp) Store() oauth.ClientAuthStore { return a.app.Store }