mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-29 20:27:16 +00:00
refactor oauth code to reduce complexity
This commit is contained in:
@@ -167,13 +167,35 @@ ATCR implements the full ATProto OAuth specification with mandatory security fea
|
||||
- **PAR** (RFC 9126) - Pushed Authorization Requests for server-to-server parameter exchange
|
||||
- **PKCE** (RFC 7636) - Proof Key for Code Exchange to prevent authorization code interception
|
||||
|
||||
**Key Components** (`pkg/auth/`):
|
||||
**Key Components** (`pkg/auth/oauth/`):
|
||||
|
||||
1. **OAuth Client** (`oauth/client.go`) - Handles authorization flow with DPoP
|
||||
2. **DPoP Transport** (`oauth/transport.go`) - HTTP RoundTripper that auto-adds DPoP headers
|
||||
3. **Token Storage** (`oauth/storage.go`) - Persists tokens and DPoP key in `~/.atcr/oauth-token.json`
|
||||
4. **Token Validator** (`atproto/validator.go`) - Validates tokens via PDS `getSession` endpoint
|
||||
5. **Exchange Handler** (`exchange/handler.go`) - Exchanges OAuth tokens for registry JWTs
|
||||
1. **Client** (`client.go`) - Core OAuth client with encapsulated configuration
|
||||
- Constructor: `NewClient(baseURL)` - accepts base URL, derives client ID/redirect URI
|
||||
- `NewClientWithKey(baseURL, dpopKey)` - for token refresh with stored DPoP key
|
||||
- `ClientID()` - computes localhost vs production client ID dynamically
|
||||
- `RedirectURI()` - returns `baseURL + "/auth/oauth/callback"`
|
||||
- `GetDefaultScopes()` - returns ATCR registry scopes
|
||||
- All OAuth flows (authorization, token exchange, refresh) in one place
|
||||
|
||||
2. **DPoP Transport** (`transport.go`) - HTTP RoundTripper that auto-adds DPoP headers
|
||||
|
||||
3. **Token Storage** (`tokenstorage.go`) - Persists refresh tokens and DPoP keys for AppView
|
||||
- File-based storage in `/var/lib/atcr/refresh-tokens.json` (AppView)
|
||||
- Client uses `~/.atcr/oauth-token.json` (credential helper)
|
||||
|
||||
4. **Refresher** (`refresher.go`) - Token refresh manager for AppView
|
||||
- Caches access tokens with automatic refresh
|
||||
- Per-DID locking prevents concurrent refresh races
|
||||
- Uses Client methods for consistency
|
||||
|
||||
5. **Server** (`server.go`) - OAuth authorization endpoints for AppView
|
||||
- `GET /auth/oauth/authorize` - starts OAuth flow
|
||||
- `GET /auth/oauth/callback` - handles OAuth callback
|
||||
- Uses Client methods for authorization and token exchange
|
||||
|
||||
6. **Interactive Flow** (`flow.go`) - Reusable OAuth flow for CLI tools
|
||||
- Used by credential helper and hold service registration
|
||||
- Two-phase callback setup ensures PAR metadata availability
|
||||
|
||||
**Authentication Flow:**
|
||||
```
|
||||
@@ -398,13 +420,19 @@ Environment variables:
|
||||
|
||||
### Development Notes
|
||||
|
||||
**General:**
|
||||
- Middleware is registered via `init()` functions in `pkg/middleware/`
|
||||
- Import `_ "atcr.io/pkg/middleware"` in main.go to register middleware
|
||||
- Storage drivers imported as `_ "github.com/distribution/distribution/v3/registry/storage/driver/s3-aws"`
|
||||
- Storage service reuses distribution's driver factory for multi-backend support
|
||||
- OAuth client uses `authelia.com/client/oauth2` for PAR support
|
||||
|
||||
**OAuth implementation:**
|
||||
- Client (`pkg/auth/oauth/client.go`) encapsulates all OAuth configuration
|
||||
- Uses `authelia.com/client/oauth2` for PAR support
|
||||
- DPoP proofs generated with `github.com/AxisCommunications/go-dpop` (auto-handles JWK)
|
||||
- Token validation via `com.atproto.server.getSession` ensures no trust in client-provided identity
|
||||
- All ATCR components use standardized `/auth/oauth/callback` path
|
||||
- Client ID generation (localhost query-based vs production metadata URL) handled internally
|
||||
|
||||
### Testing Strategy
|
||||
|
||||
@@ -432,10 +460,12 @@ When writing tests:
|
||||
2. Update `pkg/middleware/registry.go` if changing routing logic
|
||||
3. Remember: `findStorageEndpoint()` queries PDS for `io.atcr.hold` records
|
||||
|
||||
**Implementing OAuth authentication**:
|
||||
- AppView: `pkg/auth/exchange/handler.go` - validates tokens via PDS getSession
|
||||
- Client: `pkg/auth/oauth/client.go` - OAuth + DPoP flow
|
||||
- Helper: `cmd/credential-helper/` - Docker credential protocol
|
||||
**Working with OAuth client**:
|
||||
- Client is self-contained: pass `baseURL`, it handles client ID/redirect URI/scopes
|
||||
- For AppView server/refresher: use `NewClient(baseURL)` or `NewClientWithKey(baseURL, storedKey)`
|
||||
- For custom scopes: call `client.SetScopes(customScopes)` after initialization
|
||||
- Standard callback path: `/auth/oauth/callback` (used by all ATCR components)
|
||||
- Client methods are consistent across authorization, token exchange, and refresh flows
|
||||
|
||||
**Adding BYOS support for a user**:
|
||||
1. User sets environment variables (storage credentials, public URL)
|
||||
|
||||
+7
-16
@@ -862,9 +862,9 @@ func (s *HoldService) registerWithOAuth(publicURL, handle, did, pdsEndpoint stri
|
||||
fmt.Sprintf("repo:%s?action=update", atproto.HoldCrewCollection),
|
||||
}
|
||||
|
||||
// Determine base URL and client ID based on mode
|
||||
var baseURL, callbackPath string
|
||||
callbackPath = "/oauth/callback"
|
||||
// Determine base URL based on mode
|
||||
// Callback path standardized to /auth/oauth/callback across ATCR
|
||||
var baseURL string
|
||||
|
||||
if s.config.Server.TestMode {
|
||||
// Test mode: Use localhost for OAuth (browser accessible) but store real URL in hold record
|
||||
@@ -882,30 +882,21 @@ func (s *HoldService) registerWithOAuth(publicURL, handle, did, pdsEndpoint stri
|
||||
baseURL = publicURL
|
||||
}
|
||||
|
||||
// Use shared helper to construct client ID
|
||||
cfg := oauth.ClientIDConfig{
|
||||
BaseURL: baseURL,
|
||||
CallbackPath: callbackPath,
|
||||
Scopes: holdScopes,
|
||||
}
|
||||
clientID, redirectURI := cfg.MakeClientID()
|
||||
|
||||
// Run interactive OAuth flow with persistent server
|
||||
ctx := context.Background()
|
||||
result, err := oauth.RunInteractiveFlow(
|
||||
ctx,
|
||||
oauth.InteractiveFlowConfig{
|
||||
ClientID: clientID,
|
||||
RedirectURI: redirectURI,
|
||||
Handle: handle,
|
||||
Scopes: holdScopes,
|
||||
BaseURL: baseURL,
|
||||
Handle: handle,
|
||||
Scopes: holdScopes,
|
||||
},
|
||||
func(authURL string, handler *oauth.CallbackHandler, metadata *oauth.ClientMetadata) error {
|
||||
// First call (authURL empty): register callback handler
|
||||
if authURL == "" {
|
||||
// Register callback on existing server (persistent server pattern)
|
||||
// Note: metadata is not used here since hold service serves it separately on main server
|
||||
http.HandleFunc("/oauth/callback", handler.ServeHTTP)
|
||||
http.HandleFunc("/auth/oauth/callback", handler.ServeHTTP)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+5
-20
@@ -108,35 +108,20 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
|
||||
|
||||
fmt.Printf("DEBUG: Base URL for OAuth: %s\n", baseURL)
|
||||
|
||||
// 4. Get client ID from config
|
||||
clientIDConfig := oauth.ClientIDConfig{
|
||||
BaseURL: baseURL,
|
||||
CallbackPath: "/auth/oauth/callback",
|
||||
Scopes: oauth.GetDefaultScopes(),
|
||||
}
|
||||
clientID, redirectURI := clientIDConfig.MakeClientID()
|
||||
|
||||
fmt.Printf("DEBUG: Client ID: %s\n", clientID)
|
||||
fmt.Printf("DEBUG: Redirect URI: %s\n", redirectURI)
|
||||
|
||||
// 5. Create refresher
|
||||
refresher := oauth.NewRefresher(refreshStorage, clientID, redirectURI)
|
||||
// 4. Create refresher
|
||||
refresher := oauth.NewRefresher(refreshStorage, baseURL)
|
||||
// Start cleanup routine (runs every hour)
|
||||
refresher.StartCleanupRoutine(1 * time.Hour)
|
||||
|
||||
// 6. Set global refresher for middleware
|
||||
// 5. Set global refresher for middleware
|
||||
middleware.SetGlobalRefresher(refresher)
|
||||
|
||||
// 7. Create client metadata (only needed for production, not localhost)
|
||||
// For localhost, client metadata is embedded in the client_id query string
|
||||
// clientMetadata := oauth.NewClientMetadata(clientID, []string{redirectURI})
|
||||
|
||||
// 8. Create OAuth server
|
||||
// 6. Create OAuth server
|
||||
oauthServer := oauth.NewServer(refreshStorage, sessionManager, baseURL)
|
||||
// Connect server to refresher for cache invalidation
|
||||
oauthServer.SetRefresher(refresher)
|
||||
|
||||
// 9. Initialize auth keys and create token issuer
|
||||
// 7. Initialize auth keys and create token issuer
|
||||
var issuer *token.Issuer
|
||||
if config.Auth["token"] != nil {
|
||||
if err := initializeAuthKeys(config); err != nil {
|
||||
|
||||
+67
-19
@@ -10,6 +10,9 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
"authelia.com/client/oauth2"
|
||||
)
|
||||
@@ -20,26 +23,30 @@ type Client struct {
|
||||
dpopKey *ecdsa.PrivateKey
|
||||
dpopTransport *DPoPTransport
|
||||
resolver *atproto.Resolver
|
||||
clientID string
|
||||
redirectURI string
|
||||
baseUrl string
|
||||
metadata *AuthServerMetadata
|
||||
}
|
||||
|
||||
// NewClient creates a new OAuth client for ATProto
|
||||
func NewClient(clientID, redirectURI string) (*Client, error) {
|
||||
// NewClient creates a new OAuth client for ATProto from a base URL
|
||||
func NewClient(baseURL string) (*Client, error) {
|
||||
// Generate DPoP key
|
||||
dpopKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate DPoP key: %w", err)
|
||||
}
|
||||
|
||||
return NewClientWithKey(baseURL, dpopKey), nil
|
||||
}
|
||||
|
||||
// NewClientWithKey creates a new OAuth client with an existing DPoP key
|
||||
// This is useful when working with stored credentials (e.g., in AppView token refresh)
|
||||
func NewClientWithKey(baseURL string, dpopKey *ecdsa.PrivateKey) *Client {
|
||||
return &Client{
|
||||
dpopKey: dpopKey,
|
||||
dpopTransport: NewDPoPTransport(http.DefaultTransport, dpopKey),
|
||||
resolver: atproto.NewResolver(),
|
||||
clientID: clientID,
|
||||
redirectURI: redirectURI,
|
||||
}, nil
|
||||
baseUrl: baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
// InitializeForHandle discovers the authorization server for a given handle/DID
|
||||
@@ -50,6 +57,12 @@ func (c *Client) InitializeForHandle(ctx context.Context, handle string) error {
|
||||
return fmt.Errorf("failed to resolve identity: %w", err)
|
||||
}
|
||||
|
||||
return c.InitializeForPDS(ctx, pdsEndpoint)
|
||||
}
|
||||
|
||||
// InitializeForPDS discovers the authorization server for a given PDS endpoint
|
||||
// This is useful when you already know the PDS endpoint (e.g., from stored credentials)
|
||||
func (c *Client) InitializeForPDS(ctx context.Context, pdsEndpoint string) error {
|
||||
// Discover authorization server metadata
|
||||
metadata, err := DiscoverAuthServer(ctx, pdsEndpoint)
|
||||
if err != nil {
|
||||
@@ -59,18 +72,15 @@ func (c *Client) InitializeForHandle(ctx context.Context, handle string) error {
|
||||
c.metadata = metadata
|
||||
|
||||
// Configure OAuth2 client
|
||||
// Note: Both localhost and production need redirect_uri and scopes in the config
|
||||
// For localhost: client_id contains these (query-based) AND they're sent as params
|
||||
// For production: client_id is metadata URL, params come from config
|
||||
c.config = &oauth2.Config{
|
||||
ClientID: c.clientID,
|
||||
ClientID: c.ClientID(),
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: metadata.AuthorizationEndpoint,
|
||||
TokenURL: metadata.TokenEndpoint,
|
||||
PushedAuthURL: metadata.PushedAuthorizationRequestEndpoint,
|
||||
},
|
||||
RedirectURL: c.redirectURI,
|
||||
Scopes: GetDefaultScopes(),
|
||||
RedirectURL: c.RedirectURI(),
|
||||
Scopes: c.GetDefaultScopes(),
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -177,31 +187,69 @@ func (c *Client) RefreshToken(ctx context.Context, refreshToken string) (*oauth2
|
||||
Transport: c.dpopTransport,
|
||||
})
|
||||
|
||||
// Create a token source with the refresh token
|
||||
token := &oauth2.Token{
|
||||
RefreshToken: refreshToken,
|
||||
}
|
||||
|
||||
// Refresh the token
|
||||
newToken, err := c.config.TokenSource(ctx, token).Token()
|
||||
newToken, err := c.config.TokenSource(ctx, &oauth2.Token{
|
||||
RefreshToken: refreshToken,
|
||||
}).Token()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to refresh token: %w", err)
|
||||
}
|
||||
|
||||
// Set access token on transport for "ath" claim in future DPoP proofs
|
||||
c.dpopTransport.SetAccessToken(newToken.AccessToken)
|
||||
|
||||
return newToken, nil
|
||||
}
|
||||
|
||||
func (c *Client) ClientID() (string) {
|
||||
return c.ClientIDWithScopes(c.GetDefaultScopes())
|
||||
}
|
||||
|
||||
func (c *Client) ClientIDWithScopes(scopes []string) string {
|
||||
scopeStr := strings.Join(scopes, " ")
|
||||
if strings.Contains(c.baseUrl, "127.0.0.1") || strings.Contains(c.baseUrl, "localhost") {
|
||||
// Localhost: use query-based client ID
|
||||
return fmt.Sprintf("http://localhost?redirect_uri=%s&scope=%s",
|
||||
url.QueryEscape(c.RedirectURI()),
|
||||
url.QueryEscape(scopeStr))
|
||||
}
|
||||
// Production: use metadata URL
|
||||
return c.baseUrl + "/client-metadata.json"
|
||||
}
|
||||
|
||||
func (c *Client) RedirectURI() string {
|
||||
return c.baseUrl + "/auth/oauth/callback"
|
||||
}
|
||||
|
||||
// DPoPKey returns the DPoP private key
|
||||
func (c *Client) DPoPKey() *ecdsa.PrivateKey {
|
||||
return c.dpopKey
|
||||
}
|
||||
|
||||
// DPoPTransport returns the DPoP transport
|
||||
func (c *Client) DPoPTransport() *DPoPTransport {
|
||||
return c.dpopTransport
|
||||
}
|
||||
|
||||
// SetDPoPKey sets the DPoP private key (useful when loading from storage)
|
||||
func (c *Client) SetDPoPKey(key *ecdsa.PrivateKey) {
|
||||
c.dpopKey = key
|
||||
c.dpopTransport = NewDPoPTransport(http.DefaultTransport, key)
|
||||
}
|
||||
|
||||
// GetDefaultScopes returns the default OAuth scopes for ATCR registry operations
|
||||
func (c *Client) GetDefaultScopes() []string {
|
||||
return []string{
|
||||
"atproto",
|
||||
"transition:generic.full",
|
||||
"blob:application/vnd.docker.distribution.manifest.v2+json",
|
||||
fmt.Sprintf("repo:%s?action=create", atproto.ManifestCollection),
|
||||
fmt.Sprintf("repo:%s?action=update", atproto.ManifestCollection),
|
||||
fmt.Sprintf("repo:%s?action=create", atproto.TagCollection),
|
||||
fmt.Sprintf("repo:%s?action=update", atproto.TagCollection),
|
||||
}
|
||||
}
|
||||
|
||||
// generateCodeVerifier generates a PKCE code verifier
|
||||
func generateCodeVerifier() (string, error) {
|
||||
// Generate 32 random bytes
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MakeLocalhostClientID creates a query-based client ID for localhost development
|
||||
// Per ATProto OAuth spec: http://localhost?redirect_uri=...&scope=...
|
||||
func MakeLocalhostClientID(redirectURI string, scopes string) string {
|
||||
return fmt.Sprintf("http://localhost?redirect_uri=%s&scope=%s",
|
||||
url.QueryEscape(redirectURI),
|
||||
url.QueryEscape(scopes))
|
||||
}
|
||||
|
||||
// MakeProductionClientID creates a metadata URL client ID for production
|
||||
// Format: https://example.com/client-metadata.json
|
||||
func MakeProductionClientID(baseURL string) string {
|
||||
return baseURL + "/client-metadata.json"
|
||||
}
|
||||
|
||||
// IsLocalhostURL checks if a URL is localhost/127.0.0.1
|
||||
func IsLocalhostURL(urlStr string) bool {
|
||||
return strings.Contains(urlStr, "127.0.0.1") || strings.Contains(urlStr, "localhost")
|
||||
}
|
||||
|
||||
// ClientIDConfig helps construct appropriate client IDs for different environments
|
||||
type ClientIDConfig struct {
|
||||
BaseURL string // Base URL (e.g., "http://127.0.0.1:8888" or "https://example.com")
|
||||
CallbackPath string // Callback path (e.g., "/oauth/callback")
|
||||
Scopes []string // OAuth scopes
|
||||
}
|
||||
|
||||
// MakeClientID creates the appropriate client ID based on the environment
|
||||
// Returns (clientID, redirectURI)
|
||||
func (c *ClientIDConfig) MakeClientID() (string, string) {
|
||||
redirectURI := c.BaseURL + c.CallbackPath
|
||||
scopeStr := strings.Join(c.Scopes, " ")
|
||||
|
||||
if scopeStr == "" {
|
||||
scopeStr = "atproto"
|
||||
}
|
||||
|
||||
if IsLocalhostURL(c.BaseURL) {
|
||||
// Localhost: use query-based client ID
|
||||
return MakeLocalhostClientID(redirectURI, scopeStr), redirectURI
|
||||
}
|
||||
|
||||
// Production: use metadata URL
|
||||
return MakeProductionClientID(c.BaseURL), redirectURI
|
||||
}
|
||||
@@ -10,10 +10,9 @@ import (
|
||||
|
||||
// InteractiveFlowConfig configures an interactive OAuth flow
|
||||
type InteractiveFlowConfig struct {
|
||||
ClientID string
|
||||
RedirectURI string
|
||||
Handle string
|
||||
Scopes []string // optional, defaults to ["atproto"]
|
||||
BaseURL string // Base URL for OAuth callbacks (e.g., "http://127.0.0.1:8080")
|
||||
Handle string // ATProto handle or DID
|
||||
Scopes []string // Optional, defaults to GetDefaultScopes()
|
||||
}
|
||||
|
||||
// FlowResult contains the result of a successful OAuth flow
|
||||
@@ -31,8 +30,8 @@ type FlowResult struct {
|
||||
func RunInteractiveFlow(ctx context.Context, cfg InteractiveFlowConfig,
|
||||
setupCallback func(authURL string, handler *CallbackHandler, metadata *ClientMetadata) error) (*FlowResult, error) {
|
||||
|
||||
// Create OAuth client
|
||||
client, err := NewClient(cfg.ClientID, cfg.RedirectURI)
|
||||
// Create OAuth client from base URL
|
||||
client, err := NewClient(cfg.BaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OAuth client: %w", err)
|
||||
}
|
||||
@@ -58,7 +57,7 @@ func RunInteractiveFlow(ctx context.Context, cfg InteractiveFlowConfig,
|
||||
|
||||
// Create callback handler and client metadata FIRST
|
||||
callbackHandler := NewCallbackHandler(state)
|
||||
metadata := NewClientMetadata(cfg.ClientID, []string{cfg.RedirectURI})
|
||||
metadata := NewClientMetadata(client.ClientID(), []string{client.RedirectURI()})
|
||||
|
||||
// Start server BEFORE generating auth URL (so PAR can fetch metadata)
|
||||
if err := setupCallback("", callbackHandler, metadata); err != nil {
|
||||
|
||||
+12
-36
@@ -4,11 +4,8 @@ import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"authelia.com/client/oauth2"
|
||||
)
|
||||
|
||||
// AccessTokenEntry represents a cached access token
|
||||
@@ -26,18 +23,16 @@ type Refresher struct {
|
||||
mu sync.RWMutex
|
||||
refreshLocks map[string]*sync.Mutex // Per-DID locks for refresh operations
|
||||
refreshLockMu sync.Mutex // Protects refreshLocks map
|
||||
clientID string
|
||||
redirectURI string
|
||||
baseURL string
|
||||
}
|
||||
|
||||
// NewRefresher creates a new token refresher
|
||||
func NewRefresher(storage *RefreshTokenStorage, clientID, redirectURI string) *Refresher {
|
||||
func NewRefresher(storage *RefreshTokenStorage, baseURL string) *Refresher {
|
||||
return &Refresher{
|
||||
storage: storage,
|
||||
accessTokens: make(map[string]*AccessTokenEntry),
|
||||
refreshLocks: make(map[string]*sync.Mutex),
|
||||
clientID: clientID,
|
||||
redirectURI: redirectURI,
|
||||
baseURL: baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,35 +93,16 @@ func (r *Refresher) RefreshToken(ctx context.Context, did string) (string, *ecds
|
||||
return "", nil, nil, fmt.Errorf("failed to get DPoP key: %w", err)
|
||||
}
|
||||
|
||||
// Create OAuth client with DPoP transport
|
||||
dpopTransport := NewDPoPTransport(http.DefaultTransport, dpopKey)
|
||||
httpClient := &http.Client{Transport: dpopTransport}
|
||||
// Create OAuth client with stored DPoP key
|
||||
client := NewClientWithKey(r.baseURL, dpopKey)
|
||||
|
||||
// Discover PDS OAuth metadata
|
||||
metadata, err := DiscoverAuthServer(ctx, entry.PDS)
|
||||
if err != nil {
|
||||
return "", nil, nil, fmt.Errorf("failed to discover auth server: %w", err)
|
||||
// Initialize for PDS endpoint
|
||||
if err := client.InitializeForPDS(ctx, entry.PDS); err != nil {
|
||||
return "", nil, nil, fmt.Errorf("failed to initialize OAuth client: %w", err)
|
||||
}
|
||||
|
||||
// Configure OAuth2 client
|
||||
config := &oauth2.Config{
|
||||
ClientID: r.clientID,
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: metadata.AuthorizationEndpoint,
|
||||
TokenURL: metadata.TokenEndpoint,
|
||||
PushedAuthURL: metadata.PushedAuthorizationRequestEndpoint,
|
||||
},
|
||||
RedirectURL: r.redirectURI,
|
||||
Scopes: GetDefaultScopes(),
|
||||
}
|
||||
|
||||
// Create context with custom HTTP client
|
||||
ctxWithClient := context.WithValue(ctx, oauth2.HTTPClient, httpClient)
|
||||
|
||||
// Exchange refresh token for new access token
|
||||
token, err := config.TokenSource(ctxWithClient, &oauth2.Token{
|
||||
RefreshToken: entry.RefreshToken,
|
||||
}).Token()
|
||||
// Refresh the token
|
||||
token, err := client.RefreshToken(ctx, entry.RefreshToken)
|
||||
if err != nil {
|
||||
return "", nil, nil, fmt.Errorf("failed to refresh token: %w", err)
|
||||
}
|
||||
@@ -146,8 +122,8 @@ func (r *Refresher) RefreshToken(ctx context.Context, did string) (string, *ecds
|
||||
}
|
||||
}
|
||||
|
||||
// Set access token on transport for "ath" claim in future DPoP proofs
|
||||
dpopTransport.SetAccessToken(token.AccessToken)
|
||||
// Get DPoP transport (already has access token set by client.RefreshToken)
|
||||
dpopTransport := client.DPoPTransport()
|
||||
|
||||
// Cache the access token and transport
|
||||
// Expire 1 minute early to avoid edge cases
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
)
|
||||
|
||||
// GetDefaultScopes returns the default OAuth scopes for ATCR registry operations
|
||||
func GetDefaultScopes() []string {
|
||||
return []string{
|
||||
"atproto",
|
||||
"transition:generic.full",
|
||||
"blob:application/vnd.docker.distribution.manifest.v2+json",
|
||||
"blob:application/vnd.docker.image.rootfs.diff.tar.gzip",
|
||||
"blob:application/vnd.docker.container.image.v1+json",
|
||||
fmt.Sprintf("repo:%s?action=create", atproto.ManifestCollection),
|
||||
fmt.Sprintf("repo:%s?action=update", atproto.ManifestCollection),
|
||||
fmt.Sprintf("repo:%s?action=create", atproto.TagCollection),
|
||||
fmt.Sprintf("repo:%s?action=update", atproto.TagCollection),
|
||||
}
|
||||
}
|
||||
+10
-40
@@ -12,7 +12,6 @@ import (
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/auth/session"
|
||||
"authelia.com/client/oauth2"
|
||||
)
|
||||
|
||||
// Server handles OAuth authorization for the AppView
|
||||
@@ -21,8 +20,6 @@ type Server struct {
|
||||
sessionManager *session.Manager
|
||||
resolver *atproto.Resolver
|
||||
refresher *Refresher
|
||||
clientID string
|
||||
redirectURI string
|
||||
baseURL string
|
||||
states map[string]*OAuthState
|
||||
statesMu sync.RWMutex
|
||||
@@ -41,21 +38,11 @@ type OAuthState struct {
|
||||
|
||||
// NewServer creates a new OAuth server
|
||||
func NewServer(storage *RefreshTokenStorage, sessionManager *session.Manager, baseURL string) *Server {
|
||||
// Create client ID based on AppView's base URL
|
||||
cfg := ClientIDConfig{
|
||||
BaseURL: baseURL,
|
||||
CallbackPath: "/auth/oauth/callback",
|
||||
Scopes: GetDefaultScopes(),
|
||||
}
|
||||
clientID, redirectURI := cfg.MakeClientID()
|
||||
|
||||
return &Server{
|
||||
storage: storage,
|
||||
sessionManager: sessionManager,
|
||||
resolver: atproto.NewResolver(),
|
||||
refresher: nil, // Will be set via SetRefresher()
|
||||
clientID: clientID,
|
||||
redirectURI: redirectURI,
|
||||
baseURL: baseURL,
|
||||
states: make(map[string]*OAuthState),
|
||||
}
|
||||
@@ -92,9 +79,9 @@ func (s *Server) ServeAuthorize(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
fmt.Printf("DEBUG [oauth/server]: Resolved handle=%s -> did=%s, pds=%s\n", handle, did, pdsEndpoint)
|
||||
|
||||
// Create OAuth client
|
||||
fmt.Printf("DEBUG [oauth/server]: Creating OAuth client with clientID=%s, redirectURI=%s\n", s.clientID, s.redirectURI)
|
||||
client, err := NewClient(s.clientID, s.redirectURI)
|
||||
// Create OAuth client from base URL
|
||||
fmt.Printf("DEBUG [oauth/server]: Creating OAuth client for baseURL=%s\n", s.baseURL)
|
||||
client, err := NewClient(s.baseURL)
|
||||
if err != nil {
|
||||
fmt.Printf("ERROR [oauth/server]: Failed to create OAuth client: %v\n", err)
|
||||
http.Error(w, fmt.Sprintf("failed to create OAuth client: %v", err), http.StatusInternalServerError)
|
||||
@@ -178,33 +165,16 @@ func (s *Server) ServeCallback(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// exchangeCodeForSession exchanges authorization code for tokens and creates session
|
||||
func (s *Server) exchangeCodeForSession(ctx context.Context, code string, state *OAuthState) (string, error) {
|
||||
// Discover OAuth metadata
|
||||
metadata, err := DiscoverAuthServer(ctx, state.PDSEndpoint)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to discover auth server: %w", err)
|
||||
// Create OAuth client with stored DPoP key
|
||||
client := NewClientWithKey(s.baseURL, state.DPoPKey)
|
||||
|
||||
// Initialize for PDS endpoint
|
||||
if err := client.InitializeForPDS(ctx, state.PDSEndpoint); err != nil {
|
||||
return "", fmt.Errorf("failed to initialize OAuth client: %w", err)
|
||||
}
|
||||
|
||||
// Create DPoP transport
|
||||
dpopTransport := NewDPoPTransport(http.DefaultTransport, state.DPoPKey)
|
||||
httpClient := &http.Client{Transport: dpopTransport}
|
||||
|
||||
// Configure OAuth2 client
|
||||
config := &oauth2.Config{
|
||||
ClientID: s.clientID,
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: metadata.AuthorizationEndpoint,
|
||||
TokenURL: metadata.TokenEndpoint,
|
||||
PushedAuthURL: metadata.PushedAuthorizationRequestEndpoint,
|
||||
},
|
||||
RedirectURL: s.redirectURI,
|
||||
Scopes: GetDefaultScopes(),
|
||||
}
|
||||
|
||||
// Create context with custom HTTP client
|
||||
ctxWithClient := context.WithValue(ctx, oauth2.HTTPClient, httpClient)
|
||||
|
||||
// Exchange code for token
|
||||
token, err := config.Exchange(ctxWithClient, code, oauth2.VerifierOption(state.CodeVerifier))
|
||||
token, err := client.Exchange(ctx, code, state.CodeVerifier)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to exchange code: %w", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user