Go boilerplate library for building atproto apps
atproto
go
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}