refactor oauth code to reduce complexity

This commit is contained in:
Evan Jarrett
2025-10-05 16:03:03 -05:00
parent ec27a3ff0d
commit 22d0faa3a3
11 changed files with 148 additions and 223 deletions
+41 -11
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
BIN
View File
Binary file not shown.
+67 -19
View File
@@ -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
-52
View File
@@ -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
}
+6 -7
View File
@@ -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
View File
@@ -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
-22
View File
@@ -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
View File
@@ -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)
}
BIN
View File
Binary file not shown.