···2525 return
2626 }
27272828- // Get access token and DPoP transport for the user
2929- accessToken, _, dpopTransport, err := h.Refresher.GetAccessToken(r.Context(), user.DID)
2828+ // Get OAuth session for the user
2929+ session, err := h.Refresher.GetSession(r.Context(), user.DID)
3030 if err != nil {
3131- http.Error(w, "Failed to get access token: "+err.Error(), http.StatusInternalServerError)
3131+ http.Error(w, "Failed to get session: "+err.Error(), http.StatusInternalServerError)
3232 return
3333 }
34343535- // Create ATProto client with DPoP transport
3636- client := atproto.NewClientWithDPoP(user.PDSEndpoint, user.DID, accessToken, nil, dpopTransport)
3535+ // Extract access token and HTTP client from session
3636+ accessToken, _ := session.GetHostAccessData()
3737+ httpClient := session.APIClient().Client
3838+3939+ // Create ATProto client with indigo's DPoP-configured HTTP client
4040+ client := atproto.NewClientWithHTTPClient(user.PDSEndpoint, user.DID, accessToken, httpClient)
37413842 // Fetch sailor profile
3943 profile, err := atproto.GetProfile(r.Context(), client)
···86908791 holdEndpoint := r.FormValue("hold_endpoint")
88928989- // Get access token and DPoP transport for the user
9090- accessToken, _, dpopTransport, err := h.Refresher.GetAccessToken(r.Context(), user.DID)
9393+ // Get OAuth session for the user
9494+ session, err := h.Refresher.GetSession(r.Context(), user.DID)
9195 if err != nil {
9292- http.Error(w, "Failed to get access token: "+err.Error(), http.StatusInternalServerError)
9696+ http.Error(w, "Failed to get session: "+err.Error(), http.StatusInternalServerError)
9397 return
9498 }
95999696- // Create ATProto client with DPoP transport
9797- client := atproto.NewClientWithDPoP(user.PDSEndpoint, user.DID, accessToken, nil, dpopTransport)
100100+ // Extract access token and HTTP client from session
101101+ accessToken, _ := session.GetHostAccessData()
102102+ httpClient := session.APIClient().Client
103103+104104+ // Create ATProto client with indigo's DPoP-configured HTTP client
105105+ client := atproto.NewClientWithHTTPClient(user.PDSEndpoint, user.DID, accessToken, httpClient)
9810699107 // Fetch existing profile or create new one
100108 profile, err := atproto.GetProfile(r.Context(), client)
+14
pkg/atproto/client.go
···4444 }
4545}
46464747+// NewClientWithHTTPClient creates a new ATProto client with a pre-configured HTTP client
4848+// This is useful when using indigo's OAuth session which provides a DPoP-configured client
4949+// The access token will be used for Authorization headers, while the HTTP client
5050+// handles transport-level concerns (like DPoP proofs)
5151+func NewClientWithHTTPClient(pdsEndpoint, did, accessToken string, httpClient *http.Client) *Client {
5252+ return &Client{
5353+ pdsEndpoint: pdsEndpoint,
5454+ did: did,
5555+ accessToken: accessToken,
5656+ httpClient: httpClient,
5757+ useDPoP: true, // Assume DPoP when using custom client
5858+ }
5959+}
6060+4761// authHeader returns the appropriate Authorization header value
4862func (c *Client) authHeader() string {
4963 if c.useDPoP {
-269
pkg/auth/oauth/callback.go
···11-package oauth
22-33-import (
44- "crypto/rand"
55- "encoding/base64"
66- "fmt"
77- "net"
88- "net/http"
99- "net/url"
1010- "os/exec"
1111- "runtime"
1212- "strings"
1313- "time"
1414-)
1515-1616-// CallbackHandler manages OAuth callback handling
1717-type CallbackHandler struct {
1818- state string
1919- codeChan chan string
2020- errChan chan error
2121-}
2222-2323-// NewCallbackHandler creates a new callback handler
2424-func NewCallbackHandler(state string) *CallbackHandler {
2525- return &CallbackHandler{
2626- state: state,
2727- codeChan: make(chan string, 1),
2828- errChan: make(chan error, 1),
2929- }
3030-}
3131-3232-// ServeHTTP handles the OAuth callback request
3333-func (h *CallbackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
3434- code := r.URL.Query().Get("code")
3535- receivedState := r.URL.Query().Get("state")
3636- errorParam := r.URL.Query().Get("error")
3737-3838- // Validate state parameter
3939- if receivedState != h.state {
4040- h.errChan <- fmt.Errorf("invalid state parameter")
4141- http.Error(w, "Invalid state", http.StatusBadRequest)
4242- return
4343- }
4444-4545- // Check for OAuth error
4646- if errorParam != "" {
4747- h.errChan <- fmt.Errorf("OAuth error: %s (%s)",
4848- errorParam,
4949- r.URL.Query().Get("error_description"))
5050- http.Error(w, "Authorization failed", http.StatusBadRequest)
5151- return
5252- }
5353-5454- // Validate code is present
5555- if code == "" {
5656- h.errChan <- fmt.Errorf("no authorization code received")
5757- http.Error(w, "No code provided", http.StatusBadRequest)
5858- return
5959- }
6060-6161- // Send success response to browser
6262- RenderSuccessHTML(w)
6363-6464- // Send code to waiting goroutine
6565- select {
6666- case h.codeChan <- code:
6767- default:
6868- // Channel already has a value or nobody is listening
6969- }
7070-}
7171-7272-// WaitForCode waits for the OAuth callback to complete
7373-func (h *CallbackHandler) WaitForCode(timeout time.Duration) (string, error) {
7474- select {
7575- case code := <-h.codeChan:
7676- return code, nil
7777- case err := <-h.errChan:
7878- return "", err
7979- case <-time.After(timeout):
8080- return "", fmt.Errorf("OAuth timeout after %v", timeout)
8181- }
8282-}
8383-8484-// GenerateState generates a random state parameter for OAuth
8585-func GenerateState() (string, error) {
8686- // Generate 32 random bytes
8787- bytes := make([]byte, 32)
8888- if _, err := rand.Read(bytes); err != nil {
8989- return "", fmt.Errorf("failed to generate random state: %w", err)
9090- }
9191- return base64.RawURLEncoding.EncodeToString(bytes), nil
9292-}
9393-9494-// OpenBrowser opens the default browser to the given URL
9595-func OpenBrowser(url string) error {
9696- var cmd *exec.Cmd
9797-9898- switch runtime.GOOS {
9999- case "darwin":
100100- cmd = exec.Command("open", url)
101101- case "linux":
102102- cmd = exec.Command("xdg-open", url)
103103- case "windows":
104104- cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
105105- default:
106106- return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
107107- }
108108-109109- return cmd.Start()
110110-}
111111-112112-// RenderSuccessHTML renders the OAuth success page
113113-func RenderSuccessHTML(w http.ResponseWriter) {
114114- w.Header().Set("Content-Type", "text/html; charset=utf-8")
115115- fmt.Fprintf(w, `<!DOCTYPE html>
116116-<html>
117117-<head>
118118- <meta charset="UTF-8">
119119- <title>ATCR Authorization</title>
120120- <style>
121121- body {
122122- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
123123- display: flex;
124124- justify-content: center;
125125- align-items: center;
126126- height: 100vh;
127127- margin: 0;
128128- background: linear-gradient(135deg, #667eea 0%%, #764ba2 100%%);
129129- }
130130- .container {
131131- background: white;
132132- padding: 3rem;
133133- border-radius: 1rem;
134134- box-shadow: 0 10px 40px rgba(0,0,0,0.2);
135135- text-align: center;
136136- max-width: 400px;
137137- }
138138- h1 {
139139- color: #2d3748;
140140- margin: 0 0 1rem 0;
141141- font-size: 2rem;
142142- }
143143- p {
144144- color: #718096;
145145- margin: 0;
146146- font-size: 1.1rem;
147147- }
148148- .checkmark {
149149- font-size: 4rem;
150150- color: #48bb78;
151151- margin-bottom: 1rem;
152152- }
153153- </style>
154154-</head>
155155-<body>
156156- <div class="container">
157157- <div class="checkmark">✓</div>
158158- <h1>Authorization Successful!</h1>
159159- <p>You can close this window and return to the terminal.</p>
160160- </div>
161161-</body>
162162-</html>`)
163163-}
164164-165165-// StartCallbackServer creates an ephemeral HTTP server for OAuth callbacks
166166-// This is useful for CLI tools that need a temporary OAuth endpoint
167167-// Derives the listen address and paths from the metadata's ClientID and RedirectURIs
168168-func StartCallbackServer(handler *CallbackHandler, metadata *ClientMetadata) (*http.Server, error) {
169169- if len(metadata.RedirectURIs) == 0 {
170170- return nil, fmt.Errorf("no redirect URIs in metadata")
171171- }
172172-173173- // Parse redirect URI to extract listen address and callback path
174174- redirectURI := metadata.RedirectURIs[0]
175175- u, err := url.Parse(redirectURI)
176176- if err != nil {
177177- return nil, fmt.Errorf("failed to parse redirect URI: %w", err)
178178- }
179179-180180- // Extract listen address (host:port)
181181- addr := u.Host
182182- callbackPath := u.Path
183183-184184- mux := http.NewServeMux()
185185-186186- // Check if this is a query-based client ID (localhost OAuth)
187187- isQueryBased := strings.HasPrefix(metadata.ClientID, "http://localhost?")
188188-189189- var metadataPath string
190190- if !isQueryBased {
191191- // Metadata URL client ID - parse and serve metadata
192192- clientIDURL := metadata.ClientID
193193- if idx := strings.Index(clientIDURL, "?"); idx != -1 {
194194- clientIDURL = clientIDURL[:idx]
195195- }
196196-197197- clientURL, err := url.Parse(clientIDURL)
198198- if err != nil {
199199- return nil, fmt.Errorf("failed to parse client ID: %w", err)
200200- }
201201- metadataPath = clientURL.Path
202202-203203- // Serve client metadata at the path from ClientID
204204- mux.Handle(metadataPath, ServeMetadata(metadata))
205205- }
206206-207207- // Register OAuth callback handler at the path from RedirectURI
208208- mux.Handle(callbackPath, handler)
209209-210210- server := &http.Server{
211211- Addr: addr,
212212- Handler: mux,
213213- }
214214-215215- go func() {
216216- if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
217217- // Server error will be caught by WaitForCode timeout
218218- }
219219- }()
220220-221221- // Wait for server to be ready
222222- if isQueryBased {
223223- // For localhost/query-based, just check if port is listening
224224- if !waitForPort(addr, 5*time.Second) {
225225- return nil, fmt.Errorf("server failed to start within 5 seconds")
226226- }
227227- } else {
228228- // For metadata URLs, check the metadata endpoint
229229- checkURL := "http://" + addr + metadataPath
230230- if !waitForServer(checkURL, 5*time.Second) {
231231- return nil, fmt.Errorf("server failed to start within 5 seconds")
232232- }
233233- }
234234-235235- return server, nil
236236-}
237237-238238-// waitForPort checks if a TCP port is listening
239239-func waitForPort(addr string, timeout time.Duration) bool {
240240- deadline := time.Now().Add(timeout)
241241-242242- for time.Now().Before(deadline) {
243243- conn, err := net.DialTimeout("tcp", addr, 100*time.Millisecond)
244244- if err == nil {
245245- conn.Close()
246246- return true
247247- }
248248- time.Sleep(10 * time.Millisecond)
249249- }
250250- return false
251251-}
252252-253253-// waitForServer checks if the server is responding at the given URL
254254-func waitForServer(url string, timeout time.Duration) bool {
255255- deadline := time.Now().Add(timeout)
256256- client := &http.Client{Timeout: 100 * time.Millisecond}
257257-258258- for time.Now().Before(deadline) {
259259- resp, err := client.Get(url)
260260- if err == nil {
261261- resp.Body.Close()
262262- if resp.StatusCode == http.StatusOK {
263263- return true
264264- }
265265- }
266266- time.Sleep(10 * time.Millisecond)
267267- }
268268- return false
269269-}
+63-208
pkg/auth/oauth/client.go
···2233import (
44 "context"
55- "crypto/ecdsa"
66- "crypto/elliptic"
77- "crypto/rand"
88- "crypto/sha256"
99- "encoding/base64"
105 "fmt"
1111- "net/http"
1212-136 "net/url"
147 "strings"
158169 "atcr.io/pkg/atproto"
1717- "authelia.com/client/oauth2"
1010+ "github.com/bluesky-social/indigo/atproto/auth/oauth"
1111+ "github.com/bluesky-social/indigo/atproto/syntax"
1812)
19132020-// Client is an OAuth client for ATProto with DPoP support
2121-type Client struct {
2222- config *oauth2.Config
2323- dpopKey *ecdsa.PrivateKey
2424- dpopTransport *DPoPTransport
2525- resolver *atproto.Resolver
2626- baseUrl string
2727- metadata *AuthServerMetadata
1414+// App wraps indigo's ClientApp with ATCR-specific configuration
1515+type App struct {
1616+ clientApp *oauth.ClientApp
1717+ baseURL string
1818+ resolver *atproto.Resolver
2819}
29203030-// NewClient creates a new OAuth client for ATProto from a base URL
3131-func NewClient(baseURL string) (*Client, error) {
3232- // Generate DPoP key
3333- dpopKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
3434- if err != nil {
3535- return nil, fmt.Errorf("failed to generate DPoP key: %w", err)
3636- }
2121+// NewApp creates a new OAuth app for ATCR
2222+func NewApp(baseURL string, store oauth.ClientAuthStore) (*App, error) {
2323+ config := NewClientConfig(baseURL)
2424+ clientApp := oauth.NewClientApp(&config, store)
37253838- return NewClientWithKey(baseURL, dpopKey), nil
2626+ return &App{
2727+ clientApp: clientApp,
2828+ baseURL: baseURL,
2929+ resolver: atproto.NewResolver(),
3030+ }, nil
3931}
40324141-// NewClientWithKey creates a new OAuth client with an existing DPoP key
4242-// This is useful when working with stored credentials (e.g., in AppView token refresh)
4343-func NewClientWithKey(baseURL string, dpopKey *ecdsa.PrivateKey) *Client {
4444- return &Client{
4545- dpopKey: dpopKey,
4646- dpopTransport: NewDPoPTransport(http.DefaultTransport, dpopKey),
4747- resolver: atproto.NewResolver(),
4848- baseUrl: baseURL,
4949- }
5050-}
3333+// NewClientConfig creates an OAuth client configuration for ATCR
3434+func NewClientConfig(baseURL string) oauth.ClientConfig {
3535+ clientID := ClientID(baseURL)
3636+ redirectURI := RedirectURI(baseURL)
3737+ scopes := GetDefaultScopes()
51385252-// InitializeForHandle discovers the authorization server for a given handle/DID
5353-func (c *Client) InitializeForHandle(ctx context.Context, handle string) error {
5454- // Resolve handle to DID and PDS
5555- _, pdsEndpoint, err := c.resolver.ResolveIdentity(ctx, handle)
5656- if err != nil {
5757- return fmt.Errorf("failed to resolve identity: %w", err)
3939+ // Check if this is localhost (public client) or production (confidential client)
4040+ if strings.Contains(baseURL, "127.0.0.1") || strings.Contains(baseURL, "localhost") {
4141+ return oauth.NewPublicConfig(clientID, redirectURI, scopes)
5842 }
59436060- return c.InitializeForPDS(ctx, pdsEndpoint)
4444+ // Production: confidential client
4545+ // Note: Client secrets would be configured separately if needed
4646+ return oauth.NewPublicConfig(clientID, redirectURI, scopes)
6147}
62486363-// InitializeForPDS discovers the authorization server for a given PDS endpoint
6464-// This is useful when you already know the PDS endpoint (e.g., from stored credentials)
6565-func (c *Client) InitializeForPDS(ctx context.Context, pdsEndpoint string) error {
6666- // Discover authorization server metadata
6767- metadata, err := DiscoverAuthServer(ctx, pdsEndpoint)
4949+// StartAuthFlow initiates an OAuth authorization flow for a given handle
5050+// Returns the authorization URL (state is stored in the auth store)
5151+func (a *App) StartAuthFlow(ctx context.Context, handle string) (authURL string, err error) {
5252+ // Start auth flow with handle as identifier
5353+ // Indigo will resolve the handle internally
5454+ authURL, err = a.clientApp.StartAuthFlow(ctx, handle)
6855 if err != nil {
6969- return fmt.Errorf("failed to discover authorization server: %w", err)
7070- }
7171-7272- c.metadata = metadata
7373-7474- // Configure OAuth2 client
7575- c.config = &oauth2.Config{
7676- ClientID: c.ClientID(),
7777- Endpoint: oauth2.Endpoint{
7878- AuthURL: metadata.AuthorizationEndpoint,
7979- TokenURL: metadata.TokenEndpoint,
8080- PushedAuthURL: metadata.PushedAuthorizationRequestEndpoint,
8181- },
8282- RedirectURL: c.RedirectURI(),
8383- Scopes: c.GetDefaultScopes(),
5656+ return "", fmt.Errorf("failed to start auth flow: %w", err)
8457 }
85588686- return nil
8787-}
8888-8989-// SetScopes sets custom OAuth scopes (must be called after InitializeForHandle)
9090-func (c *Client) SetScopes(scopes []string) {
9191- if c.config != nil {
9292- c.config.Scopes = scopes
9393- }
5959+ return authURL, nil
9460}
95619696-// AuthorizeURL generates the authorization URL with PKCE
9797-func (c *Client) AuthorizeURL(state string) (authURL string, codeVerifier string, err error) {
9898- if c.config == nil {
9999- return "", "", fmt.Errorf("client not initialized - call InitializeForHandle first")
100100- }
101101-102102- // Generate PKCE code verifier
103103- codeVerifier, err = generateCodeVerifier()
6262+// ProcessCallback processes an OAuth callback with authorization code and state
6363+// Returns ClientSessionData which contains the session information
6464+func (a *App) ProcessCallback(ctx context.Context, params url.Values) (*oauth.ClientSessionData, error) {
6565+ sessionData, err := a.clientApp.ProcessCallback(ctx, params)
10466 if err != nil {
105105- return "", "", fmt.Errorf("failed to generate code verifier: %w", err)
106106- }
107107-108108- // Generate code challenge
109109- codeChallenge := generateCodeChallenge(codeVerifier)
110110-111111- // Use PAR (Pushed Authorization Request) if supported
112112- if c.metadata.PushedAuthorizationRequestEndpoint != "" {
113113- authURL, err = c.authorizeURLWithPAR(state, codeChallenge)
114114- if err != nil {
115115- return "", "", fmt.Errorf("PAR failed: %w", err)
116116- }
117117- } else {
118118- // Fallback to standard authorization
119119- authURL = c.config.AuthCodeURL(state,
120120- oauth2.SetAuthURLParam("code_challenge", codeChallenge),
121121- oauth2.SetAuthURLParam("code_challenge_method", "S256"),
122122- )
6767+ return nil, fmt.Errorf("failed to process OAuth callback: %w", err)
12368 }
12469125125- return authURL, codeVerifier, nil
7070+ return sessionData, nil
12671}
12772128128-// authorizeURLWithPAR uses Pushed Authorization Request
129129-func (c *Client) authorizeURLWithPAR(state, codeChallenge string) (string, error) {
130130- fmt.Printf("DEBUG [oauth/client]: Starting PAR request\n")
131131- fmt.Printf("DEBUG [oauth/client]: - client_id: %s\n", c.config.ClientID)
132132- fmt.Printf("DEBUG [oauth/client]: - redirect_uri: %s\n", c.config.RedirectURL)
133133- fmt.Printf("DEBUG [oauth/client]: - scope: %v\n", c.config.Scopes)
134134- fmt.Printf("DEBUG [oauth/client]: - state: %s\n", state)
135135- fmt.Printf("DEBUG [oauth/client]: - code_challenge_method: S256\n")
136136- fmt.Printf("DEBUG [oauth/client]: - PAR endpoint: %s\n", c.config.Endpoint.PushedAuthURL)
137137-138138- // Create HTTP client with DPoP transport
139139- ctx := context.WithValue(context.Background(), oauth2.HTTPClient, &http.Client{
140140- Transport: c.dpopTransport,
141141- })
142142-143143- // Use authelia's PushedAuth method
144144- authURL, _, err := c.config.PushedAuth(ctx, state,
145145- oauth2.SetAuthURLParam("code_challenge", codeChallenge),
146146- oauth2.SetAuthURLParam("code_challenge_method", "S256"),
147147- )
7373+// ResumeSession resumes an existing OAuth session
7474+// Returns a ClientSession that can be used to make authenticated requests
7575+func (a *App) ResumeSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSession, error) {
7676+ session, err := a.clientApp.ResumeSession(ctx, did, sessionID)
14877 if err != nil {
149149- fmt.Printf("ERROR [oauth/client]: PAR request failed: %v\n", err)
150150- return "", err
7878+ return nil, fmt.Errorf("failed to resume session: %w", err)
15179 }
15280153153- fmt.Printf("DEBUG [oauth/client]: PAR successful, authURL: %s\n", authURL.String())
154154- return authURL.String(), nil
8181+ return session, nil
15582}
15683157157-// Exchange exchanges an authorization code for an access token
158158-func (c *Client) Exchange(ctx context.Context, code, codeVerifier string) (*oauth2.Token, error) {
159159- if c.config == nil {
160160- return nil, fmt.Errorf("client not initialized")
161161- }
162162-163163- // Create HTTP client with DPoP transport
164164- ctx = context.WithValue(ctx, oauth2.HTTPClient, &http.Client{
165165- Transport: c.dpopTransport,
166166- })
167167-168168- // Exchange the code for a token
169169- token, err := c.config.Exchange(ctx, code,
170170- oauth2.SetAuthURLParam("code_verifier", codeVerifier),
171171- )
172172- if err != nil {
173173- return nil, fmt.Errorf("failed to exchange code: %w", err)
174174- }
175175-176176- return token, nil
8484+// GetClientApp returns the underlying indigo ClientApp
8585+// This is useful for advanced use cases that need direct access
8686+func (a *App) GetClientApp() *oauth.ClientApp {
8787+ return a.clientApp
17788}
17889179179-// RefreshToken refreshes an access token using a refresh token
180180-func (c *Client) RefreshToken(ctx context.Context, refreshToken string) (*oauth2.Token, error) {
181181- if c.config == nil {
182182- return nil, fmt.Errorf("client not initialized")
183183- }
184184-185185- // Create HTTP client with DPoP transport
186186- ctx = context.WithValue(ctx, oauth2.HTTPClient, &http.Client{
187187- Transport: c.dpopTransport,
188188- })
189189-190190- // Refresh the token
191191- newToken, err := c.config.TokenSource(ctx, &oauth2.Token{
192192- RefreshToken: refreshToken,
193193- }).Token()
194194- if err != nil {
195195- return nil, fmt.Errorf("failed to refresh token: %w", err)
196196- }
197197-198198- // Set access token on transport for "ath" claim in future DPoP proofs
199199- c.dpopTransport.SetAccessToken(newToken.AccessToken)
200200-201201- return newToken, nil
202202-}
203203-204204-func (c *Client) ClientID() string {
205205- return c.ClientIDWithScopes(c.GetDefaultScopes())
9090+// ClientID generates the OAuth client ID for ATCR
9191+func ClientID(baseURL string) string {
9292+ return ClientIDWithScopes(baseURL, GetDefaultScopes())
20693}
20794208208-func (c *Client) ClientIDWithScopes(scopes []string) string {
9595+// ClientIDWithScopes generates a client ID with custom scopes
9696+func ClientIDWithScopes(baseURL string, scopes []string) string {
20997 scopeStr := strings.Join(scopes, " ")
210210- if strings.Contains(c.baseUrl, "127.0.0.1") || strings.Contains(c.baseUrl, "localhost") {
9898+ if strings.Contains(baseURL, "127.0.0.1") || strings.Contains(baseURL, "localhost") {
21199 // Localhost: use query-based client ID
212100 return fmt.Sprintf("http://localhost?redirect_uri=%s&scope=%s",
213213- url.QueryEscape(c.RedirectURI()),
101101+ url.QueryEscape(RedirectURI(baseURL)),
214102 url.QueryEscape(scopeStr))
215103 }
216104 // Production: use metadata URL
217217- return c.baseUrl + "/client-metadata.json"
105105+ return baseURL + "/client-metadata.json"
218106}
219107220220-func (c *Client) RedirectURI() string {
221221- return c.baseUrl + "/auth/oauth/callback"
222222-}
223223-224224-// DPoPKey returns the DPoP private key
225225-func (c *Client) DPoPKey() *ecdsa.PrivateKey {
226226- return c.dpopKey
227227-}
228228-229229-// DPoPTransport returns the DPoP transport
230230-func (c *Client) DPoPTransport() *DPoPTransport {
231231- return c.dpopTransport
232232-}
233233-234234-// SetDPoPKey sets the DPoP private key (useful when loading from storage)
235235-func (c *Client) SetDPoPKey(key *ecdsa.PrivateKey) {
236236- c.dpopKey = key
237237- c.dpopTransport = NewDPoPTransport(http.DefaultTransport, key)
108108+// RedirectURI returns the OAuth redirect URI for ATCR
109109+func RedirectURI(baseURL string) string {
110110+ return baseURL + "/auth/oauth/callback"
238111}
239112240113// GetDefaultScopes returns the default OAuth scopes for ATCR registry operations
241241-func (c *Client) GetDefaultScopes() []string {
114114+func GetDefaultScopes() []string {
242115 return []string{
243116 "atproto",
244117 "transition:generic.full",
···249122 fmt.Sprintf("repo:%s?action=update", atproto.TagCollection),
250123 }
251124}
252252-253253-// generateCodeVerifier generates a PKCE code verifier
254254-func generateCodeVerifier() (string, error) {
255255- // Generate 32 random bytes
256256- bytes := make([]byte, 32)
257257- if _, err := rand.Read(bytes); err != nil {
258258- return "", err
259259- }
260260-261261- // Base64 URL encode
262262- return base64.RawURLEncoding.EncodeToString(bytes), nil
263263-}
264264-265265-// generateCodeChallenge generates a PKCE code challenge from a verifier
266266-func generateCodeChallenge(verifier string) string {
267267- hash := sha256.Sum256([]byte(verifier))
268268- return base64.RawURLEncoding.EncodeToString(hash[:])
269269-}
-120
pkg/auth/oauth/discovery.go
···11-package oauth
22-33-import (
44- "context"
55- "encoding/json"
66- "fmt"
77- "net/http"
88-)
99-1010-// ProtectedResourceMetadata represents the OAuth protected resource metadata
1111-// as defined in ATProto OAuth spec
1212-type ProtectedResourceMetadata struct {
1313- Resource string `json:"resource"`
1414- AuthorizationServers []string `json:"authorization_servers"`
1515-}
1616-1717-// AuthServerMetadata represents the OAuth authorization server metadata
1818-// as defined in RFC 8414
1919-type AuthServerMetadata struct {
2020- Issuer string `json:"issuer"`
2121- AuthorizationEndpoint string `json:"authorization_endpoint"`
2222- TokenEndpoint string `json:"token_endpoint"`
2323- PushedAuthorizationRequestEndpoint string `json:"pushed_authorization_request_endpoint,omitempty"`
2424- RegistrationEndpoint string `json:"registration_endpoint,omitempty"`
2525- JWKsURI string `json:"jwks_uri,omitempty"`
2626- ScopesSupported []string `json:"scopes_supported,omitempty"`
2727- ResponseTypesSupported []string `json:"response_types_supported,omitempty"`
2828- GrantTypesSupported []string `json:"grant_types_supported,omitempty"`
2929- TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported,omitempty"`
3030- DPoPSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported,omitempty"`
3131- CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported,omitempty"`
3232- AuthorizationResponseIssParameterSupported bool `json:"authorization_response_iss_parameter_supported,omitempty"`
3333-}
3434-3535-// DiscoverProtectedResource discovers the protected resource metadata
3636-// from the PDS endpoint to find the authorization servers
3737-func DiscoverProtectedResource(ctx context.Context, pdsEndpoint string) (*ProtectedResourceMetadata, error) {
3838- // Construct the well-known URL per ATProto OAuth spec
3939- discoveryURL := fmt.Sprintf("%s/.well-known/oauth-protected-resource", pdsEndpoint)
4040-4141- req, err := http.NewRequestWithContext(ctx, "GET", discoveryURL, nil)
4242- if err != nil {
4343- return nil, fmt.Errorf("failed to create protected resource discovery request: %w", err)
4444- }
4545-4646- client := &http.Client{}
4747- resp, err := client.Do(req)
4848- if err != nil {
4949- return nil, fmt.Errorf("failed to fetch protected resource metadata: %w", err)
5050- }
5151- defer resp.Body.Close()
5252-5353- if resp.StatusCode != http.StatusOK {
5454- return nil, fmt.Errorf("protected resource discovery failed with status %d", resp.StatusCode)
5555- }
5656-5757- var metadata ProtectedResourceMetadata
5858- if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil {
5959- return nil, fmt.Errorf("failed to decode protected resource metadata: %w", err)
6060- }
6161-6262- // Validate required fields
6363- if len(metadata.AuthorizationServers) == 0 {
6464- return nil, fmt.Errorf("protected resource metadata missing authorization_servers")
6565- }
6666-6767- return &metadata, nil
6868-}
6969-7070-// DiscoverAuthServer discovers the OAuth authorization server metadata
7171-// using the ATProto two-step discovery process:
7272-// 1. Fetch protected resource metadata from PDS to get authorization server URL
7373-// 2. Fetch authorization server metadata from that URL
7474-func DiscoverAuthServer(ctx context.Context, pdsEndpoint string) (*AuthServerMetadata, error) {
7575- // Step 1: Discover the authorization server URL from the protected resource
7676- protectedResource, err := DiscoverProtectedResource(ctx, pdsEndpoint)
7777- if err != nil {
7878- return nil, fmt.Errorf("step 1 failed - discover protected resource from PDS %s: %w", pdsEndpoint, err)
7979- }
8080-8181- // Use the first authorization server (ATProto spec allows multiple, but typically one)
8282- authServerURL := protectedResource.AuthorizationServers[0]
8383-8484- // Step 2: Fetch authorization server metadata
8585- discoveryURL := fmt.Sprintf("%s/.well-known/oauth-authorization-server", authServerURL)
8686-8787- req, err := http.NewRequestWithContext(ctx, "GET", discoveryURL, nil)
8888- if err != nil {
8989- return nil, fmt.Errorf("step 2 failed - create request: %w", err)
9090- }
9191-9292- client := &http.Client{}
9393- resp, err := client.Do(req)
9494- if err != nil {
9595- return nil, fmt.Errorf("step 2 failed - fetch auth server metadata from %s: %w", discoveryURL, err)
9696- }
9797- defer resp.Body.Close()
9898-9999- if resp.StatusCode != http.StatusOK {
100100- return nil, fmt.Errorf("step 2 failed - auth server discovery at %s returned status %d", discoveryURL, resp.StatusCode)
101101- }
102102-103103- var metadata AuthServerMetadata
104104- if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil {
105105- return nil, fmt.Errorf("failed to decode authorization server metadata: %w", err)
106106- }
107107-108108- // Validate required fields
109109- if metadata.Issuer == "" {
110110- return nil, fmt.Errorf("authorization server metadata missing issuer")
111111- }
112112- if metadata.AuthorizationEndpoint == "" {
113113- return nil, fmt.Errorf("authorization server metadata missing authorization_endpoint")
114114- }
115115- if metadata.TokenEndpoint == "" {
116116- return nil, fmt.Errorf("authorization server metadata missing token_endpoint")
117117- }
118118-119119- return &metadata, nil
120120-}
-97
pkg/auth/oauth/flow.go
···11-package oauth
22-33-import (
44- "context"
55- "fmt"
66- "time"
77-88- "authelia.com/client/oauth2"
99-)
1010-1111-// InteractiveFlowConfig configures an interactive OAuth flow
1212-type InteractiveFlowConfig struct {
1313- BaseURL string // Base URL for OAuth callbacks (e.g., "http://127.0.0.1:8080")
1414- Handle string // ATProto handle or DID
1515- Scopes []string // Optional, defaults to GetDefaultScopes()
1616-}
1717-1818-// FlowResult contains the result of a successful OAuth flow
1919-type FlowResult struct {
2020- Token *oauth2.Token
2121- Client *Client // OAuth client with DPoP key set
2222-}
2323-2424-// RunInteractiveFlow executes an interactive OAuth authorization code flow
2525-// The setupCallback function is called TWICE:
2626-// 1. First with authURL="" to start the server (before PAR)
2727-// 2. Then with the actual authURL to display it to the user (after PAR)
2828-//
2929-// This two-phase approach ensures the server is running before PAR tries to fetch client metadata
3030-func RunInteractiveFlow(ctx context.Context, cfg InteractiveFlowConfig,
3131- setupCallback func(authURL string, handler *CallbackHandler, metadata *ClientMetadata) error) (*FlowResult, error) {
3232-3333- // Create OAuth client from base URL
3434- client, err := NewClient(cfg.BaseURL)
3535- if err != nil {
3636- return nil, fmt.Errorf("failed to create OAuth client: %w", err)
3737- }
3838-3939- // Initialize for the given handle
4040- initCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
4141- defer cancel()
4242-4343- if err := client.InitializeForHandle(initCtx, cfg.Handle); err != nil {
4444- return nil, fmt.Errorf("failed to initialize client: %w", err)
4545- }
4646-4747- // Set scopes if provided
4848- if len(cfg.Scopes) > 0 {
4949- client.SetScopes(cfg.Scopes)
5050- }
5151-5252- // Generate state for OAuth flow
5353- state, err := GenerateState()
5454- if err != nil {
5555- return nil, fmt.Errorf("failed to generate state: %w", err)
5656- }
5757-5858- // Create callback handler and client metadata FIRST
5959- callbackHandler := NewCallbackHandler(state)
6060- metadata := NewClientMetadata(client.ClientID(), []string{client.RedirectURI()})
6161-6262- // Start server BEFORE generating auth URL (so PAR can fetch metadata)
6363- if err := setupCallback("", callbackHandler, metadata); err != nil {
6464- return nil, fmt.Errorf("callback setup failed: %w", err)
6565- }
6666-6767- // NOW generate authorization URL with PKCE (PAR can succeed)
6868- authURL, codeVerifier, err := client.AuthorizeURL(state)
6969- if err != nil {
7070- return nil, fmt.Errorf("failed to generate auth URL: %w", err)
7171- }
7272-7373- // Display the auth URL (callback gets called again with URL)
7474- if err := setupCallback(authURL, callbackHandler, metadata); err != nil {
7575- return nil, fmt.Errorf("failed to display auth URL: %w", err)
7676- }
7777-7878- // Wait for callback (5 minute timeout)
7979- code, err := callbackHandler.WaitForCode(5 * time.Minute)
8080- if err != nil {
8181- return nil, err
8282- }
8383-8484- // Exchange code for token
8585- exchangeCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
8686- defer cancel()
8787-8888- token, err := client.Exchange(exchangeCtx, code, codeVerifier)
8989- if err != nil {
9090- return nil, fmt.Errorf("failed to exchange code: %w", err)
9191- }
9292-9393- return &FlowResult{
9494- Token: token,
9595- Client: client,
9696- }, nil
9797-}