···167167- **PAR** (RFC 9126) - Pushed Authorization Requests for server-to-server parameter exchange
168168- **PKCE** (RFC 7636) - Proof Key for Code Exchange to prevent authorization code interception
169169170170-**Key Components** (`pkg/auth/`):
170170+**Key Components** (`pkg/auth/oauth/`):
171171+172172+1. **Client** (`client.go`) - Core OAuth client with encapsulated configuration
173173+ - Constructor: `NewClient(baseURL)` - accepts base URL, derives client ID/redirect URI
174174+ - `NewClientWithKey(baseURL, dpopKey)` - for token refresh with stored DPoP key
175175+ - `ClientID()` - computes localhost vs production client ID dynamically
176176+ - `RedirectURI()` - returns `baseURL + "/auth/oauth/callback"`
177177+ - `GetDefaultScopes()` - returns ATCR registry scopes
178178+ - All OAuth flows (authorization, token exchange, refresh) in one place
171179172172-1. **OAuth Client** (`oauth/client.go`) - Handles authorization flow with DPoP
173173-2. **DPoP Transport** (`oauth/transport.go`) - HTTP RoundTripper that auto-adds DPoP headers
174174-3. **Token Storage** (`oauth/storage.go`) - Persists tokens and DPoP key in `~/.atcr/oauth-token.json`
175175-4. **Token Validator** (`atproto/validator.go`) - Validates tokens via PDS `getSession` endpoint
176176-5. **Exchange Handler** (`exchange/handler.go`) - Exchanges OAuth tokens for registry JWTs
180180+2. **DPoP Transport** (`transport.go`) - HTTP RoundTripper that auto-adds DPoP headers
181181+182182+3. **Token Storage** (`tokenstorage.go`) - Persists refresh tokens and DPoP keys for AppView
183183+ - File-based storage in `/var/lib/atcr/refresh-tokens.json` (AppView)
184184+ - Client uses `~/.atcr/oauth-token.json` (credential helper)
185185+186186+4. **Refresher** (`refresher.go`) - Token refresh manager for AppView
187187+ - Caches access tokens with automatic refresh
188188+ - Per-DID locking prevents concurrent refresh races
189189+ - Uses Client methods for consistency
190190+191191+5. **Server** (`server.go`) - OAuth authorization endpoints for AppView
192192+ - `GET /auth/oauth/authorize` - starts OAuth flow
193193+ - `GET /auth/oauth/callback` - handles OAuth callback
194194+ - Uses Client methods for authorization and token exchange
195195+196196+6. **Interactive Flow** (`flow.go`) - Reusable OAuth flow for CLI tools
197197+ - Used by credential helper and hold service registration
198198+ - Two-phase callback setup ensures PAR metadata availability
177199178200**Authentication Flow:**
179201```
···398420399421### Development Notes
400422423423+**General:**
401424- Middleware is registered via `init()` functions in `pkg/middleware/`
402425- Import `_ "atcr.io/pkg/middleware"` in main.go to register middleware
403426- Storage drivers imported as `_ "github.com/distribution/distribution/v3/registry/storage/driver/s3-aws"`
404427- Storage service reuses distribution's driver factory for multi-backend support
405405-- OAuth client uses `authelia.com/client/oauth2` for PAR support
428428+429429+**OAuth implementation:**
430430+- Client (`pkg/auth/oauth/client.go`) encapsulates all OAuth configuration
431431+- Uses `authelia.com/client/oauth2` for PAR support
406432- DPoP proofs generated with `github.com/AxisCommunications/go-dpop` (auto-handles JWK)
407433- Token validation via `com.atproto.server.getSession` ensures no trust in client-provided identity
434434+- All ATCR components use standardized `/auth/oauth/callback` path
435435+- Client ID generation (localhost query-based vs production metadata URL) handled internally
408436409437### Testing Strategy
410438···4324602. Update `pkg/middleware/registry.go` if changing routing logic
4334613. Remember: `findStorageEndpoint()` queries PDS for `io.atcr.hold` records
434462435435-**Implementing OAuth authentication**:
436436-- AppView: `pkg/auth/exchange/handler.go` - validates tokens via PDS getSession
437437-- Client: `pkg/auth/oauth/client.go` - OAuth + DPoP flow
438438-- Helper: `cmd/credential-helper/` - Docker credential protocol
463463+**Working with OAuth client**:
464464+- Client is self-contained: pass `baseURL`, it handles client ID/redirect URI/scopes
465465+- For AppView server/refresher: use `NewClient(baseURL)` or `NewClientWithKey(baseURL, storedKey)`
466466+- For custom scopes: call `client.SetScopes(customScopes)` after initialization
467467+- Standard callback path: `/auth/oauth/callback` (used by all ATCR components)
468468+- Client methods are consistent across authorization, token exchange, and refresh flows
439469440470**Adding BYOS support for a user**:
4414711. User sets environment variables (storage credentials, public URL)
+7-16
cmd/hold/main.go
···862862 fmt.Sprintf("repo:%s?action=update", atproto.HoldCrewCollection),
863863 }
864864865865- // Determine base URL and client ID based on mode
866866- var baseURL, callbackPath string
867867- callbackPath = "/oauth/callback"
865865+ // Determine base URL based on mode
866866+ // Callback path standardized to /auth/oauth/callback across ATCR
867867+ var baseURL string
868868869869 if s.config.Server.TestMode {
870870 // Test mode: Use localhost for OAuth (browser accessible) but store real URL in hold record
···882882 baseURL = publicURL
883883 }
884884885885- // Use shared helper to construct client ID
886886- cfg := oauth.ClientIDConfig{
887887- BaseURL: baseURL,
888888- CallbackPath: callbackPath,
889889- Scopes: holdScopes,
890890- }
891891- clientID, redirectURI := cfg.MakeClientID()
892892-893885 // Run interactive OAuth flow with persistent server
894886 ctx := context.Background()
895887 result, err := oauth.RunInteractiveFlow(
896888 ctx,
897889 oauth.InteractiveFlowConfig{
898898- ClientID: clientID,
899899- RedirectURI: redirectURI,
900900- Handle: handle,
901901- Scopes: holdScopes,
890890+ BaseURL: baseURL,
891891+ Handle: handle,
892892+ Scopes: holdScopes,
902893 },
903894 func(authURL string, handler *oauth.CallbackHandler, metadata *oauth.ClientMetadata) error {
904895 // First call (authURL empty): register callback handler
905896 if authURL == "" {
906897 // Register callback on existing server (persistent server pattern)
907898 // Note: metadata is not used here since hold service serves it separately on main server
908908- http.HandleFunc("/oauth/callback", handler.ServeHTTP)
899899+ http.HandleFunc("/auth/oauth/callback", handler.ServeHTTP)
909900 return nil
910901 }
911902
+5-20
cmd/registry/serve.go
···108108109109 fmt.Printf("DEBUG: Base URL for OAuth: %s\n", baseURL)
110110111111- // 4. Get client ID from config
112112- clientIDConfig := oauth.ClientIDConfig{
113113- BaseURL: baseURL,
114114- CallbackPath: "/auth/oauth/callback",
115115- Scopes: oauth.GetDefaultScopes(),
116116- }
117117- clientID, redirectURI := clientIDConfig.MakeClientID()
118118-119119- fmt.Printf("DEBUG: Client ID: %s\n", clientID)
120120- fmt.Printf("DEBUG: Redirect URI: %s\n", redirectURI)
121121-122122- // 5. Create refresher
123123- refresher := oauth.NewRefresher(refreshStorage, clientID, redirectURI)
111111+ // 4. Create refresher
112112+ refresher := oauth.NewRefresher(refreshStorage, baseURL)
124113 // Start cleanup routine (runs every hour)
125114 refresher.StartCleanupRoutine(1 * time.Hour)
126115127127- // 6. Set global refresher for middleware
116116+ // 5. Set global refresher for middleware
128117 middleware.SetGlobalRefresher(refresher)
129118130130- // 7. Create client metadata (only needed for production, not localhost)
131131- // For localhost, client metadata is embedded in the client_id query string
132132- // clientMetadata := oauth.NewClientMetadata(clientID, []string{redirectURI})
133133-134134- // 8. Create OAuth server
119119+ // 6. Create OAuth server
135120 oauthServer := oauth.NewServer(refreshStorage, sessionManager, baseURL)
136121 // Connect server to refresher for cache invalidation
137122 oauthServer.SetRefresher(refresher)
138123139139- // 9. Initialize auth keys and create token issuer
124124+ // 7. Initialize auth keys and create token issuer
140125 var issuer *token.Issuer
141126 if config.Auth["token"] != nil {
142127 if err := initializeAuthKeys(config); err != nil {
hold
This is a binary file and will not be displayed.
+67-19
pkg/auth/oauth/client.go
···1010 "fmt"
1111 "net/http"
12121313+ "net/url"
1414+ "strings"
1515+1316 "atcr.io/pkg/atproto"
1417 "authelia.com/client/oauth2"
1518)
···2023 dpopKey *ecdsa.PrivateKey
2124 dpopTransport *DPoPTransport
2225 resolver *atproto.Resolver
2323- clientID string
2424- redirectURI string
2626+ baseUrl string
2527 metadata *AuthServerMetadata
2628}
27292828-// NewClient creates a new OAuth client for ATProto
2929-func NewClient(clientID, redirectURI string) (*Client, error) {
3030+// NewClient creates a new OAuth client for ATProto from a base URL
3131+func NewClient(baseURL string) (*Client, error) {
3032 // Generate DPoP key
3133 dpopKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
3234 if err != nil {
3335 return nil, fmt.Errorf("failed to generate DPoP key: %w", err)
3436 }
35373838+ return NewClientWithKey(baseURL, dpopKey), nil
3939+}
4040+4141+// 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 {
3644 return &Client{
3745 dpopKey: dpopKey,
3846 dpopTransport: NewDPoPTransport(http.DefaultTransport, dpopKey),
3947 resolver: atproto.NewResolver(),
4040- clientID: clientID,
4141- redirectURI: redirectURI,
4242- }, nil
4848+ baseUrl: baseURL,
4949+ }
4350}
44514552// InitializeForHandle discovers the authorization server for a given handle/DID
···5057 return fmt.Errorf("failed to resolve identity: %w", err)
5158 }
52596060+ return c.InitializeForPDS(ctx, pdsEndpoint)
6161+}
6262+6363+// 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 {
5366 // Discover authorization server metadata
5467 metadata, err := DiscoverAuthServer(ctx, pdsEndpoint)
5568 if err != nil {
···5972 c.metadata = metadata
60736174 // Configure OAuth2 client
6262- // Note: Both localhost and production need redirect_uri and scopes in the config
6363- // For localhost: client_id contains these (query-based) AND they're sent as params
6464- // For production: client_id is metadata URL, params come from config
6575 c.config = &oauth2.Config{
6666- ClientID: c.clientID,
7676+ ClientID: c.ClientID(),
6777 Endpoint: oauth2.Endpoint{
6878 AuthURL: metadata.AuthorizationEndpoint,
6979 TokenURL: metadata.TokenEndpoint,
7080 PushedAuthURL: metadata.PushedAuthorizationRequestEndpoint,
7181 },
7272- RedirectURL: c.redirectURI,
7373- Scopes: GetDefaultScopes(),
8282+ RedirectURL: c.RedirectURI(),
8383+ Scopes: c.GetDefaultScopes(),
7484 }
75857686 return nil
···177187 Transport: c.dpopTransport,
178188 })
179189180180- // Create a token source with the refresh token
181181- token := &oauth2.Token{
182182- RefreshToken: refreshToken,
183183- }
184184-185190 // Refresh the token
186186- newToken, err := c.config.TokenSource(ctx, token).Token()
191191+ newToken, err := c.config.TokenSource(ctx, &oauth2.Token{
192192+ RefreshToken: refreshToken,
193193+ }).Token()
187194 if err != nil {
188195 return nil, fmt.Errorf("failed to refresh token: %w", err)
189196 }
190197198198+ // Set access token on transport for "ath" claim in future DPoP proofs
199199+ c.dpopTransport.SetAccessToken(newToken.AccessToken)
200200+191201 return newToken, nil
192202}
193203204204+func (c *Client) ClientID() (string) {
205205+ return c.ClientIDWithScopes(c.GetDefaultScopes())
206206+}
207207+208208+func (c *Client) ClientIDWithScopes(scopes []string) string {
209209+ scopeStr := strings.Join(scopes, " ")
210210+ if strings.Contains(c.baseUrl, "127.0.0.1") || strings.Contains(c.baseUrl, "localhost") {
211211+ // Localhost: use query-based client ID
212212+ return fmt.Sprintf("http://localhost?redirect_uri=%s&scope=%s",
213213+ url.QueryEscape(c.RedirectURI()),
214214+ url.QueryEscape(scopeStr))
215215+ }
216216+ // Production: use metadata URL
217217+ return c.baseUrl + "/client-metadata.json"
218218+}
219219+220220+func (c *Client) RedirectURI() string {
221221+ return c.baseUrl + "/auth/oauth/callback"
222222+}
223223+194224// DPoPKey returns the DPoP private key
195225func (c *Client) DPoPKey() *ecdsa.PrivateKey {
196226 return c.dpopKey
197227}
198228229229+// DPoPTransport returns the DPoP transport
230230+func (c *Client) DPoPTransport() *DPoPTransport {
231231+ return c.dpopTransport
232232+}
233233+199234// SetDPoPKey sets the DPoP private key (useful when loading from storage)
200235func (c *Client) SetDPoPKey(key *ecdsa.PrivateKey) {
201236 c.dpopKey = key
202237 c.dpopTransport = NewDPoPTransport(http.DefaultTransport, key)
238238+}
239239+240240+// GetDefaultScopes returns the default OAuth scopes for ATCR registry operations
241241+func (c *Client) GetDefaultScopes() []string {
242242+ return []string{
243243+ "atproto",
244244+ "transition:generic.full",
245245+ "blob:application/vnd.docker.distribution.manifest.v2+json",
246246+ fmt.Sprintf("repo:%s?action=create", atproto.ManifestCollection),
247247+ fmt.Sprintf("repo:%s?action=update", atproto.ManifestCollection),
248248+ fmt.Sprintf("repo:%s?action=create", atproto.TagCollection),
249249+ fmt.Sprintf("repo:%s?action=update", atproto.TagCollection),
250250+ }
203251}
204252205253// generateCodeVerifier generates a PKCE code verifier
-52
pkg/auth/oauth/client_id.go
···11-package oauth
22-33-import (
44- "fmt"
55- "net/url"
66- "strings"
77-)
88-99-// MakeLocalhostClientID creates a query-based client ID for localhost development
1010-// Per ATProto OAuth spec: http://localhost?redirect_uri=...&scope=...
1111-func MakeLocalhostClientID(redirectURI string, scopes string) string {
1212- return fmt.Sprintf("http://localhost?redirect_uri=%s&scope=%s",
1313- url.QueryEscape(redirectURI),
1414- url.QueryEscape(scopes))
1515-}
1616-1717-// MakeProductionClientID creates a metadata URL client ID for production
1818-// Format: https://example.com/client-metadata.json
1919-func MakeProductionClientID(baseURL string) string {
2020- return baseURL + "/client-metadata.json"
2121-}
2222-2323-// IsLocalhostURL checks if a URL is localhost/127.0.0.1
2424-func IsLocalhostURL(urlStr string) bool {
2525- return strings.Contains(urlStr, "127.0.0.1") || strings.Contains(urlStr, "localhost")
2626-}
2727-2828-// ClientIDConfig helps construct appropriate client IDs for different environments
2929-type ClientIDConfig struct {
3030- BaseURL string // Base URL (e.g., "http://127.0.0.1:8888" or "https://example.com")
3131- CallbackPath string // Callback path (e.g., "/oauth/callback")
3232- Scopes []string // OAuth scopes
3333-}
3434-3535-// MakeClientID creates the appropriate client ID based on the environment
3636-// Returns (clientID, redirectURI)
3737-func (c *ClientIDConfig) MakeClientID() (string, string) {
3838- redirectURI := c.BaseURL + c.CallbackPath
3939- scopeStr := strings.Join(c.Scopes, " ")
4040-4141- if scopeStr == "" {
4242- scopeStr = "atproto"
4343- }
4444-4545- if IsLocalhostURL(c.BaseURL) {
4646- // Localhost: use query-based client ID
4747- return MakeLocalhostClientID(redirectURI, scopeStr), redirectURI
4848- }
4949-5050- // Production: use metadata URL
5151- return MakeProductionClientID(c.BaseURL), redirectURI
5252-}
+6-7
pkg/auth/oauth/flow.go
···10101111// InteractiveFlowConfig configures an interactive OAuth flow
1212type InteractiveFlowConfig struct {
1313- ClientID string
1414- RedirectURI string
1515- Handle string
1616- Scopes []string // optional, defaults to ["atproto"]
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()
1716}
18171918// FlowResult contains the result of a successful OAuth flow
···3130func RunInteractiveFlow(ctx context.Context, cfg InteractiveFlowConfig,
3231 setupCallback func(authURL string, handler *CallbackHandler, metadata *ClientMetadata) error) (*FlowResult, error) {
33323434- // Create OAuth client
3535- client, err := NewClient(cfg.ClientID, cfg.RedirectURI)
3333+ // Create OAuth client from base URL
3434+ client, err := NewClient(cfg.BaseURL)
3635 if err != nil {
3736 return nil, fmt.Errorf("failed to create OAuth client: %w", err)
3837 }
···58575958 // Create callback handler and client metadata FIRST
6059 callbackHandler := NewCallbackHandler(state)
6161- metadata := NewClientMetadata(cfg.ClientID, []string{cfg.RedirectURI})
6060+ metadata := NewClientMetadata(client.ClientID(), []string{client.RedirectURI()})
62616362 // Start server BEFORE generating auth URL (so PAR can fetch metadata)
6463 if err := setupCallback("", callbackHandler, metadata); err != nil {
+12-36
pkg/auth/oauth/refresher.go
···44 "context"
55 "crypto/ecdsa"
66 "fmt"
77- "net/http"
87 "sync"
98 "time"
1010-1111- "authelia.com/client/oauth2"
129)
13101411// AccessTokenEntry represents a cached access token
···2623 mu sync.RWMutex
2724 refreshLocks map[string]*sync.Mutex // Per-DID locks for refresh operations
2825 refreshLockMu sync.Mutex // Protects refreshLocks map
2929- clientID string
3030- redirectURI string
2626+ baseURL string
3127}
32283329// NewRefresher creates a new token refresher
3434-func NewRefresher(storage *RefreshTokenStorage, clientID, redirectURI string) *Refresher {
3030+func NewRefresher(storage *RefreshTokenStorage, baseURL string) *Refresher {
3531 return &Refresher{
3632 storage: storage,
3733 accessTokens: make(map[string]*AccessTokenEntry),
3834 refreshLocks: make(map[string]*sync.Mutex),
3939- clientID: clientID,
4040- redirectURI: redirectURI,
3535+ baseURL: baseURL,
4136 }
4237}
4338···9893 return "", nil, nil, fmt.Errorf("failed to get DPoP key: %w", err)
9994 }
10095101101- // Create OAuth client with DPoP transport
102102- dpopTransport := NewDPoPTransport(http.DefaultTransport, dpopKey)
103103- httpClient := &http.Client{Transport: dpopTransport}
9696+ // Create OAuth client with stored DPoP key
9797+ client := NewClientWithKey(r.baseURL, dpopKey)
10498105105- // Discover PDS OAuth metadata
106106- metadata, err := DiscoverAuthServer(ctx, entry.PDS)
107107- if err != nil {
108108- return "", nil, nil, fmt.Errorf("failed to discover auth server: %w", err)
109109- }
110110-111111- // Configure OAuth2 client
112112- config := &oauth2.Config{
113113- ClientID: r.clientID,
114114- Endpoint: oauth2.Endpoint{
115115- AuthURL: metadata.AuthorizationEndpoint,
116116- TokenURL: metadata.TokenEndpoint,
117117- PushedAuthURL: metadata.PushedAuthorizationRequestEndpoint,
118118- },
119119- RedirectURL: r.redirectURI,
120120- Scopes: GetDefaultScopes(),
9999+ // Initialize for PDS endpoint
100100+ if err := client.InitializeForPDS(ctx, entry.PDS); err != nil {
101101+ return "", nil, nil, fmt.Errorf("failed to initialize OAuth client: %w", err)
121102 }
122103123123- // Create context with custom HTTP client
124124- ctxWithClient := context.WithValue(ctx, oauth2.HTTPClient, httpClient)
125125-126126- // Exchange refresh token for new access token
127127- token, err := config.TokenSource(ctxWithClient, &oauth2.Token{
128128- RefreshToken: entry.RefreshToken,
129129- }).Token()
104104+ // Refresh the token
105105+ token, err := client.RefreshToken(ctx, entry.RefreshToken)
130106 if err != nil {
131107 return "", nil, nil, fmt.Errorf("failed to refresh token: %w", err)
132108 }
···146122 }
147123 }
148124149149- // Set access token on transport for "ath" claim in future DPoP proofs
150150- dpopTransport.SetAccessToken(token.AccessToken)
125125+ // Get DPoP transport (already has access token set by client.RefreshToken)
126126+ dpopTransport := client.DPoPTransport()
151127152128 // Cache the access token and transport
153129 // Expire 1 minute early to avoid edge cases