mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-16 15:24:15 +00:00
cleanup more auth
This commit is contained in:
@@ -12,7 +12,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
atprotoclient "atcr.io/pkg/atproto"
|
||||
"github.com/bluesky-social/indigo/atproto/identity"
|
||||
"github.com/bluesky-social/indigo/atproto/syntax"
|
||||
)
|
||||
|
||||
// CachedSession represents a cached session
|
||||
@@ -25,7 +26,7 @@ type CachedSession struct {
|
||||
|
||||
// SessionValidator validates ATProto credentials
|
||||
type SessionValidator struct {
|
||||
resolver *atprotoclient.Resolver
|
||||
directory identity.Directory
|
||||
httpClient *http.Client
|
||||
cache map[string]*CachedSession
|
||||
cacheMu sync.RWMutex
|
||||
@@ -34,7 +35,7 @@ type SessionValidator struct {
|
||||
// NewSessionValidator creates a new ATProto session validator
|
||||
func NewSessionValidator() *SessionValidator {
|
||||
return &SessionValidator{
|
||||
resolver: atprotoclient.NewResolver(),
|
||||
directory: identity.DefaultDirectory(),
|
||||
httpClient: &http.Client{},
|
||||
cache: make(map[string]*CachedSession),
|
||||
}
|
||||
@@ -86,11 +87,22 @@ type SessionResponse struct {
|
||||
// Returns the user's DID and PDS endpoint if valid
|
||||
func (v *SessionValidator) ValidateCredentials(ctx context.Context, identifier, password string) (did, pdsEndpoint string, err error) {
|
||||
// Resolve identifier (handle or DID) to PDS endpoint
|
||||
resolvedDID, pds, err := v.resolver.ResolveIdentity(ctx, identifier)
|
||||
atID, err := syntax.ParseAtIdentifier(identifier)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("invalid identifier %q: %w", identifier, err)
|
||||
}
|
||||
|
||||
ident, err := v.directory.Lookup(ctx, *atID)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to resolve identity %q: %w", identifier, err)
|
||||
}
|
||||
|
||||
resolvedDID := ident.DID.String()
|
||||
pds := ident.PDSEndpoint()
|
||||
if pds == "" {
|
||||
return "", "", fmt.Errorf("no PDS endpoint found for %q", identifier)
|
||||
}
|
||||
|
||||
fmt.Printf("DEBUG: Resolved %s to DID=%s, PDS=%s\n", identifier, resolvedDID, pds)
|
||||
|
||||
// Create session with the PDS
|
||||
@@ -119,11 +131,22 @@ func (v *SessionValidator) CreateSessionAndGetToken(ctx context.Context, identif
|
||||
fmt.Printf("DEBUG [atproto/session]: No cached session for %s, creating new session\n", identifier)
|
||||
|
||||
// Resolve identifier to PDS endpoint
|
||||
did, pds, err := v.resolver.ResolveIdentity(ctx, identifier)
|
||||
atID, err := syntax.ParseAtIdentifier(identifier)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("invalid identifier %q: %w", identifier, err)
|
||||
}
|
||||
|
||||
ident, err := v.directory.Lookup(ctx, *atID)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("failed to resolve identity %q: %w", identifier, err)
|
||||
}
|
||||
|
||||
did = ident.DID.String()
|
||||
pds := ident.PDSEndpoint()
|
||||
if pds == "" {
|
||||
return "", "", "", fmt.Errorf("no PDS endpoint found for %q", identifier)
|
||||
}
|
||||
|
||||
// Create session
|
||||
sessionResp, err := v.createSession(ctx, pds, identifier, password)
|
||||
if err != nil {
|
||||
|
||||
@@ -7,7 +7,8 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
mainAtproto "atcr.io/pkg/atproto"
|
||||
"github.com/bluesky-social/indigo/atproto/identity"
|
||||
"github.com/bluesky-social/indigo/atproto/syntax"
|
||||
)
|
||||
|
||||
// TokenValidator validates ATProto OAuth access tokens
|
||||
@@ -90,12 +91,22 @@ func (v *TokenValidator) ValidateToken(ctx context.Context, pdsEndpoint, accessT
|
||||
// dpopProof is optional - if provided, uses DPoP auth; otherwise uses Bearer
|
||||
func (v *TokenValidator) ValidateTokenWithResolver(ctx context.Context, handle, accessToken, dpopProof string) (*SessionInfo, error) {
|
||||
// Resolve handle to PDS endpoint
|
||||
resolver := mainAtproto.NewResolver()
|
||||
_, pdsEndpoint, err := resolver.ResolveIdentity(ctx, handle)
|
||||
directory := identity.DefaultDirectory()
|
||||
atID, err := syntax.ParseAtIdentifier(handle)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid identifier %q: %w", handle, err)
|
||||
}
|
||||
|
||||
ident, err := directory.Lookup(ctx, *atID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve PDS endpoint: %w", err)
|
||||
}
|
||||
|
||||
pdsEndpoint := ident.PDSEndpoint()
|
||||
if pdsEndpoint == "" {
|
||||
return nil, fmt.Errorf("no PDS endpoint found for %q", handle)
|
||||
}
|
||||
|
||||
// Validate token against the PDS
|
||||
return v.ValidateToken(ctx, pdsEndpoint, accessToken, dpopProof)
|
||||
}
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
package exchange
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"atcr.io/pkg/auth"
|
||||
"atcr.io/pkg/auth/session"
|
||||
"atcr.io/pkg/auth/token"
|
||||
)
|
||||
|
||||
// Handler handles /auth/exchange requests (session token -> registry JWT)
|
||||
type Handler struct {
|
||||
issuer *token.Issuer
|
||||
sessionManager *session.Manager
|
||||
}
|
||||
|
||||
// NewHandler creates a new exchange handler
|
||||
func NewHandler(issuer *token.Issuer, sessionManager *session.Manager) *Handler {
|
||||
return &Handler{
|
||||
issuer: issuer,
|
||||
sessionManager: sessionManager,
|
||||
}
|
||||
}
|
||||
|
||||
// ExchangeRequest represents the request to exchange a session token for registry JWT
|
||||
type ExchangeRequest struct {
|
||||
Scope []string `json:"scope"` // Requested Docker scopes
|
||||
}
|
||||
|
||||
// ExchangeResponse represents the response from /auth/exchange
|
||||
type ExchangeResponse struct {
|
||||
Token string `json:"token"`
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
// ServeHTTP handles the exchange request
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract session token from Authorization header
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
http.Error(w, "authorization header required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse Bearer token
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
http.Error(w, "invalid authorization header format", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
sessionToken := parts[1]
|
||||
|
||||
// Validate session token
|
||||
sessionClaims, err := h.sessionManager.Validate(sessionToken)
|
||||
if err != nil {
|
||||
fmt.Printf("DEBUG [exchange]: session validation failed: %v\n", err)
|
||||
http.Error(w, fmt.Sprintf("invalid session token: %v", err), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("DEBUG [exchange]: session validated for DID=%s, handle=%s\n", sessionClaims.DID, sessionClaims.Handle)
|
||||
|
||||
// Parse request body for scopes
|
||||
var req ExchangeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse and validate scopes
|
||||
access, err := auth.ParseScope(req.Scope)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("invalid scope: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate access permissions
|
||||
if err := auth.ValidateAccess(sessionClaims.DID, sessionClaims.Handle, access); err != nil {
|
||||
http.Error(w, fmt.Sprintf("access denied: %v", err), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Issue registry JWT token
|
||||
tokenString, err := h.issuer.Issue(sessionClaims.DID, access)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to issue token: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return response
|
||||
resp := ExchangeResponse{
|
||||
Token: tokenString,
|
||||
AccessToken: tokenString,
|
||||
ExpiresIn: int(h.issuer.Expiration().Seconds()),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to encode response: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers the exchange handler with the provided mux
|
||||
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.Handle("/auth/exchange", h)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// OpenBrowser opens the default browser to the given URL
|
||||
func OpenBrowser(url string) error {
|
||||
var cmd *exec.Cmd
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
cmd = exec.Command("open", url)
|
||||
case "linux":
|
||||
cmd = exec.Command("xdg-open", url)
|
||||
case "windows":
|
||||
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
|
||||
default:
|
||||
return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
|
||||
}
|
||||
|
||||
return cmd.Start()
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
"github.com/bluesky-social/indigo/atproto/auth/oauth"
|
||||
"github.com/bluesky-social/indigo/atproto/identity"
|
||||
"github.com/bluesky-social/indigo/atproto/syntax"
|
||||
)
|
||||
|
||||
@@ -15,7 +16,7 @@ import (
|
||||
type App struct {
|
||||
clientApp *oauth.ClientApp
|
||||
baseURL string
|
||||
resolver *atproto.Resolver
|
||||
directory identity.Directory
|
||||
}
|
||||
|
||||
// NewApp creates a new OAuth app for ATCR
|
||||
@@ -26,7 +27,7 @@ func NewApp(baseURL string, store oauth.ClientAuthStore) (*App, error) {
|
||||
return &App{
|
||||
clientApp: clientApp,
|
||||
baseURL: baseURL,
|
||||
resolver: atproto.NewResolver(),
|
||||
directory: identity.DefaultDirectory(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/bluesky-social/indigo/atproto/auth/oauth"
|
||||
)
|
||||
|
||||
// InteractiveResult contains the result of an interactive OAuth flow
|
||||
type InteractiveResult struct {
|
||||
SessionData *oauth.ClientSessionData
|
||||
Session *oauth.ClientSession
|
||||
App *App
|
||||
}
|
||||
|
||||
// RunInteractiveFlow runs an interactive OAuth flow for CLI tools
|
||||
// This is a simplified wrapper around indigo's OAuth flow
|
||||
func RunInteractiveFlow(
|
||||
ctx context.Context,
|
||||
baseURL string,
|
||||
handle string,
|
||||
scopes []string,
|
||||
onAuthURL func(string) error,
|
||||
) (*InteractiveResult, error) {
|
||||
// Create temporary file store for this flow
|
||||
store, err := NewFileStore("/tmp/atcr-oauth-temp.json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OAuth store: %w", err)
|
||||
}
|
||||
|
||||
// Create OAuth app
|
||||
app, err := NewApp(baseURL, store)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OAuth app: %w", err)
|
||||
}
|
||||
|
||||
// Set custom scopes if provided
|
||||
if len(scopes) > 0 {
|
||||
// Note: indigo's ClientApp doesn't expose SetScopes, so we need to use default scopes
|
||||
// This is a limitation of the current implementation
|
||||
// TODO: Enhance if custom scopes are needed
|
||||
}
|
||||
|
||||
// Start auth flow
|
||||
authURL, err := app.StartAuthFlow(ctx, handle)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to start auth flow: %w", err)
|
||||
}
|
||||
|
||||
// Call the callback to display the auth URL
|
||||
if err := onAuthURL(authURL); err != nil {
|
||||
return nil, fmt.Errorf("auth URL callback failed: %w", err)
|
||||
}
|
||||
|
||||
// Wait for OAuth callback
|
||||
// The callback will be handled by the http.HandleFunc registered by the caller
|
||||
// We need to wait for ProcessCallback to be called
|
||||
// This is a bit awkward, but matches the old pattern
|
||||
|
||||
// Setup a channel to receive callback params
|
||||
callbackChan := make(chan url.Values, 1)
|
||||
var setupOnce sync.Once
|
||||
|
||||
// Return a function that the caller can use to process the callback
|
||||
// This is called from the HTTP handler
|
||||
processCallback := func(params url.Values) (*oauth.ClientSessionData, error) {
|
||||
setupOnce.Do(func() {
|
||||
callbackChan <- params
|
||||
})
|
||||
sessionData, err := app.ProcessCallback(ctx, params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to process callback: %w", err)
|
||||
}
|
||||
return sessionData, nil
|
||||
}
|
||||
|
||||
// Wait for callback with timeout
|
||||
select {
|
||||
case params := <-callbackChan:
|
||||
sessionData, err := processCallback(params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Resume session to get ClientSession
|
||||
session, err := app.ResumeSession(ctx, sessionData.AccountDID, sessionData.SessionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resume session: %w", err)
|
||||
}
|
||||
|
||||
return &InteractiveResult{
|
||||
SessionData: sessionData,
|
||||
Session: session,
|
||||
App: app,
|
||||
}, nil
|
||||
case <-time.After(5 * time.Minute):
|
||||
return nil, fmt.Errorf("OAuth flow timed out after 5 minutes")
|
||||
}
|
||||
}
|
||||
|
||||
// InteractiveFlowWithCallback runs an interactive OAuth flow with explicit callback handling
|
||||
// This version allows the caller to register the callback handler before starting the flow
|
||||
func InteractiveFlowWithCallback(
|
||||
ctx context.Context,
|
||||
baseURL string,
|
||||
handle string,
|
||||
scopes []string,
|
||||
registerCallback func(handler http.HandlerFunc) error,
|
||||
displayAuthURL func(string) error,
|
||||
) (*InteractiveResult, error) {
|
||||
// Create temporary file store for this flow
|
||||
store, err := NewFileStore("/tmp/atcr-oauth-temp.json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OAuth store: %w", err)
|
||||
}
|
||||
|
||||
// Create OAuth app
|
||||
app, err := NewApp(baseURL, store)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OAuth app: %w", err)
|
||||
}
|
||||
|
||||
// Channel to receive callback result
|
||||
resultChan := make(chan *InteractiveResult, 1)
|
||||
errorChan := make(chan error, 1)
|
||||
|
||||
// Create callback handler
|
||||
callbackHandler := func(w http.ResponseWriter, r *http.Request) {
|
||||
// Process callback
|
||||
sessionData, err := app.ProcessCallback(r.Context(), r.URL.Query())
|
||||
if err != nil {
|
||||
errorChan <- fmt.Errorf("failed to process callback: %w", err)
|
||||
http.Error(w, "OAuth callback failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Resume session
|
||||
session, err := app.ResumeSession(r.Context(), sessionData.AccountDID, sessionData.SessionID)
|
||||
if err != nil {
|
||||
errorChan <- fmt.Errorf("failed to resume session: %w", err)
|
||||
http.Error(w, "Failed to resume session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Send result
|
||||
resultChan <- &InteractiveResult{
|
||||
SessionData: sessionData,
|
||||
Session: session,
|
||||
App: app,
|
||||
}
|
||||
|
||||
// Return success to browser
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprintf(w, "<html><body><h1>Authorization Successful!</h1><p>You can close this window and return to the terminal.</p></body></html>")
|
||||
}
|
||||
|
||||
// Register callback handler
|
||||
if err := registerCallback(callbackHandler); err != nil {
|
||||
return nil, fmt.Errorf("failed to register callback: %w", err)
|
||||
}
|
||||
|
||||
// Start auth flow
|
||||
authURL, err := app.StartAuthFlow(ctx, handle)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to start auth flow: %w", err)
|
||||
}
|
||||
|
||||
// Display auth URL
|
||||
if err := displayAuthURL(authURL); err != nil {
|
||||
return nil, fmt.Errorf("failed to display auth URL: %w", err)
|
||||
}
|
||||
|
||||
// Wait for callback result
|
||||
select {
|
||||
case result := <-resultChan:
|
||||
return result, nil
|
||||
case err := <-errorChan:
|
||||
return nil, err
|
||||
case <-time.After(5 * time.Minute):
|
||||
return nil, fmt.Errorf("OAuth flow timed out after 5 minutes")
|
||||
}
|
||||
}
|
||||
+40
-52
@@ -7,7 +7,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/auth/session"
|
||||
"github.com/bluesky-social/indigo/atproto/syntax"
|
||||
)
|
||||
|
||||
// UISessionStore is the interface for UI session management
|
||||
@@ -18,16 +18,14 @@ type UISessionStore interface {
|
||||
// Server handles OAuth authorization for the AppView
|
||||
type Server struct {
|
||||
app *App
|
||||
sessionManager *session.Manager
|
||||
refresher *Refresher
|
||||
uiSessionStore UISessionStore
|
||||
}
|
||||
|
||||
// NewServer creates a new OAuth server
|
||||
func NewServer(app *App, sessionManager *session.Manager) *Server {
|
||||
func NewServer(app *App) *Server {
|
||||
return &Server{
|
||||
app: app,
|
||||
sessionManager: sessionManager,
|
||||
app: app,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +102,7 @@ func (s *Server) ServeCallback(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Printf("DEBUG [oauth/server]: Invalidated cached session for DID=%s after creating new session\n", did)
|
||||
}
|
||||
|
||||
// We need to get the handle for the session token
|
||||
// We need to get the handle for UI sessions and settings redirect
|
||||
// Resolve DID to handle using our resolver
|
||||
handle, err := s.resolveHandle(r.Context(), did)
|
||||
if err != nil {
|
||||
@@ -112,17 +110,10 @@ func (s *Server) ServeCallback(w http.ResponseWriter, r *http.Request) {
|
||||
handle = did // Fallback to DID if resolution fails
|
||||
}
|
||||
|
||||
// Create session token for credential helper
|
||||
sessionToken, err := s.sessionManager.Create(did, handle)
|
||||
if err != nil {
|
||||
s.renderError(w, fmt.Sprintf("Failed to create session token: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this is a UI login (has oauth_return_to cookie)
|
||||
if cookie, err := r.Cookie("oauth_return_to"); err == nil && s.uiSessionStore != nil {
|
||||
// Create UI session
|
||||
uiSessionID, err := s.uiSessionStore.Create(did, handle, sessionData.HostURL, 24*time.Hour)
|
||||
// Create UI session (30 days to match OAuth refresh token lifetime)
|
||||
uiSessionID, err := s.uiSessionStore.Create(did, handle, sessionData.HostURL, 30*24*time.Hour)
|
||||
if err != nil {
|
||||
s.renderError(w, fmt.Sprintf("Failed to create UI session: %v", err))
|
||||
return
|
||||
@@ -133,7 +124,7 @@ func (s *Server) ServeCallback(w http.ResponseWriter, r *http.Request) {
|
||||
Name: "atcr_session",
|
||||
Value: uiSessionID,
|
||||
Path: "/",
|
||||
MaxAge: 86400, // 24 hours
|
||||
MaxAge: 30 * 86400, // 30 days
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
@@ -157,39 +148,36 @@ func (s *Server) ServeCallback(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Render success page with session token (for credential helper)
|
||||
s.renderSuccess(w, sessionToken, handle)
|
||||
// Non-UI flow: redirect to settings to get API key
|
||||
s.renderRedirectToSettings(w, handle)
|
||||
}
|
||||
|
||||
// resolveHandle attempts to resolve a DID to a handle
|
||||
// This is a best-effort helper - we use the resolver to look up the handle
|
||||
func (s *Server) resolveHandle(ctx context.Context, did string) (string, error) {
|
||||
// Parse the DID document to get the handle
|
||||
// Note: This is a simple implementation - in production we might want to cache this
|
||||
doc, err := s.app.resolver.ResolveDIDDocument(ctx, did)
|
||||
// This is a best-effort helper - we use the directory to look up the handle
|
||||
func (s *Server) resolveHandle(ctx context.Context, didStr string) (string, error) {
|
||||
// Parse DID
|
||||
did, err := syntax.ParseDID(didStr)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to resolve DID document: %w", err)
|
||||
return "", fmt.Errorf("invalid DID: %w", err)
|
||||
}
|
||||
|
||||
// Try to find a handle in the alsoKnownAs field
|
||||
for _, aka := range doc.AlsoKnownAs {
|
||||
if len(aka) > 5 && aka[:5] == "at://" {
|
||||
return aka[5:], nil
|
||||
}
|
||||
// Look up identity
|
||||
ident, err := s.app.directory.LookupDID(ctx, did)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to lookup DID: %w", err)
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no handle found in DID document")
|
||||
// Return handle (may be handle.invalid if verification failed)
|
||||
return ident.Handle.String(), nil
|
||||
}
|
||||
|
||||
// renderSuccess renders the success page
|
||||
func (s *Server) renderSuccess(w http.ResponseWriter, sessionToken, handle string) {
|
||||
tmpl := template.Must(template.New("success").Parse(successTemplate))
|
||||
// renderRedirectToSettings redirects to the settings page to generate an API key
|
||||
func (s *Server) renderRedirectToSettings(w http.ResponseWriter, handle string) {
|
||||
tmpl := template.Must(template.New("redirect").Parse(redirectToSettingsTemplate))
|
||||
data := struct {
|
||||
SessionToken string
|
||||
Handle string
|
||||
Handle string
|
||||
}{
|
||||
SessionToken: sessionToken,
|
||||
Handle: handle,
|
||||
Handle: handle,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
@@ -216,35 +204,35 @@ func (s *Server) renderError(w http.ResponseWriter, message string) {
|
||||
|
||||
// HTML templates
|
||||
|
||||
const successTemplate = `
|
||||
const redirectToSettingsTemplate = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Authorization Successful - ATCR</title>
|
||||
<meta http-equiv="refresh" content="3;url=/settings">
|
||||
<style>
|
||||
body { font-family: sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
|
||||
.success { background: #d4edda; border: 1px solid #c3e6cb; padding: 20px; border-radius: 5px; }
|
||||
code { background: #f5f5f5; padding: 10px; display: block; margin: 10px 0; word-break: break-all; }
|
||||
.copy-btn { background: #007bff; color: white; border: none; padding: 10px 20px; cursor: pointer; border-radius: 5px; }
|
||||
.copy-btn:hover { background: #0056b3; }
|
||||
.info { background: #d1ecf1; border: 1px solid #bee5eb; padding: 15px; border-radius: 5px; margin-top: 15px; }
|
||||
a { color: #007bff; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="success">
|
||||
<h1>✓ Authorization Successful!</h1>
|
||||
<p>You have successfully authorized ATCR to access your ATProto account: <strong>{{.Handle}}</strong></p>
|
||||
<p>Copy the session token below and paste it into your credential helper:</p>
|
||||
<code id="token">{{.SessionToken}}</code>
|
||||
<button class="copy-btn" onclick="copyToken()">Copy Token</button>
|
||||
<p>Redirecting to settings page to generate your API key...</p>
|
||||
<p>If not redirected, <a href="/settings">click here</a>.</p>
|
||||
</div>
|
||||
<div class="info">
|
||||
<h3>Next Steps:</h3>
|
||||
<ol>
|
||||
<li>Generate an API key on the settings page</li>
|
||||
<li>Copy the API key (shown once!)</li>
|
||||
<li>Use it with: <code>docker login atcr.io -u {{.Handle}} -p [your-api-key]</code></li>
|
||||
</ol>
|
||||
</div>
|
||||
<script>
|
||||
function copyToken() {
|
||||
const token = document.getElementById('token').textContent;
|
||||
navigator.clipboard.writeText(token).then(() => {
|
||||
alert('Token copied to clipboard!');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/bluesky-social/indigo/atproto/auth/oauth"
|
||||
"github.com/bluesky-social/indigo/atproto/syntax"
|
||||
)
|
||||
|
||||
// FileStore implements oauth.ClientAuthStore with file-based persistence
|
||||
type FileStore struct {
|
||||
path string
|
||||
sessions map[string]*oauth.ClientSessionData // Key: "did:sessionID"
|
||||
requests map[string]*oauth.AuthRequestData // Key: state
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// FileStoreData represents the JSON structure stored on disk
|
||||
type FileStoreData struct {
|
||||
Sessions map[string]*oauth.ClientSessionData `json:"sessions"`
|
||||
Requests map[string]*oauth.AuthRequestData `json:"requests"`
|
||||
}
|
||||
|
||||
// NewFileStore creates a new file-based OAuth store
|
||||
func NewFileStore(path string) (*FileStore, error) {
|
||||
store := &FileStore{
|
||||
path: path,
|
||||
sessions: make(map[string]*oauth.ClientSessionData),
|
||||
requests: make(map[string]*oauth.AuthRequestData),
|
||||
}
|
||||
|
||||
// Load existing data if file exists
|
||||
if err := store.load(); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("failed to load store: %w", err)
|
||||
}
|
||||
// File doesn't exist yet, that's ok
|
||||
}
|
||||
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// GetDefaultStorePath returns the default storage path for OAuth data
|
||||
func GetDefaultStorePath() (string, error) {
|
||||
// For AppView: /var/lib/atcr/oauth-sessions.json
|
||||
// For CLI tools: ~/.atcr/oauth-sessions.json
|
||||
|
||||
// Check if running as a service (has write access to /var/lib)
|
||||
servicePath := "/var/lib/atcr/oauth-sessions.json"
|
||||
if err := os.MkdirAll(filepath.Dir(servicePath), 0700); err == nil {
|
||||
// Can write to /var/lib, use service path
|
||||
return servicePath, nil
|
||||
}
|
||||
|
||||
// Fall back to user home directory
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get home directory: %w", err)
|
||||
}
|
||||
|
||||
atcrDir := filepath.Join(homeDir, ".atcr")
|
||||
if err := os.MkdirAll(atcrDir, 0700); err != nil {
|
||||
return "", fmt.Errorf("failed to create .atcr directory: %w", err)
|
||||
}
|
||||
|
||||
return filepath.Join(atcrDir, "oauth-sessions.json"), nil
|
||||
}
|
||||
|
||||
// GetSession retrieves a session by DID and session ID
|
||||
func (s *FileStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
key := makeSessionKey(did.String(), sessionID)
|
||||
session, ok := s.sessions[key]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("session not found: %s/%s", did, sessionID)
|
||||
}
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// SaveSession saves or updates a session (upsert)
|
||||
func (s *FileStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
key := makeSessionKey(sess.AccountDID.String(), sess.SessionID)
|
||||
s.sessions[key] = &sess
|
||||
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// DeleteSession removes a session
|
||||
func (s *FileStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
key := makeSessionKey(did.String(), sessionID)
|
||||
delete(s.sessions, key)
|
||||
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// GetAuthRequestInfo retrieves authentication request data by state
|
||||
func (s *FileStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
request, ok := s.requests[state]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("auth request not found: %s", state)
|
||||
}
|
||||
|
||||
return request, nil
|
||||
}
|
||||
|
||||
// SaveAuthRequestInfo saves authentication request data
|
||||
func (s *FileStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.requests[info.State] = &info
|
||||
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// DeleteAuthRequestInfo removes authentication request data
|
||||
func (s *FileStore) DeleteAuthRequestInfo(ctx context.Context, state string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
delete(s.requests, state)
|
||||
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// CleanupExpired removes expired sessions and auth requests
|
||||
// Should be called periodically (e.g., every hour)
|
||||
func (s *FileStore) CleanupExpired() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
modified := false
|
||||
|
||||
// Clean up auth requests older than 10 minutes
|
||||
// (OAuth flows should complete quickly)
|
||||
for state := range s.requests {
|
||||
// Note: AuthRequestData doesn't have a timestamp in indigo's implementation
|
||||
// For now, we'll rely on the OAuth server's cleanup routine
|
||||
// or we could extend AuthRequestData with metadata
|
||||
_ = state // Placeholder for future expiration logic
|
||||
}
|
||||
|
||||
// Sessions don't have expiry in the data structure
|
||||
// Cleanup would need to be token-based (check token expiry)
|
||||
// For now, manual cleanup via DeleteSession
|
||||
_ = now
|
||||
|
||||
if modified {
|
||||
return s.save()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListSessions returns all stored sessions for debugging/management
|
||||
func (s *FileStore) ListSessions() map[string]*oauth.ClientSessionData {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
// Return a copy to prevent external modification
|
||||
result := make(map[string]*oauth.ClientSessionData)
|
||||
for k, v := range s.sessions {
|
||||
result[k] = v
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// load reads data from disk
|
||||
func (s *FileStore) load() error {
|
||||
data, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var storeData FileStoreData
|
||||
if err := json.Unmarshal(data, &storeData); err != nil {
|
||||
return fmt.Errorf("failed to parse store: %w", err)
|
||||
}
|
||||
|
||||
if storeData.Sessions != nil {
|
||||
s.sessions = storeData.Sessions
|
||||
}
|
||||
if storeData.Requests != nil {
|
||||
s.requests = storeData.Requests
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// save writes data to disk
|
||||
func (s *FileStore) save() error {
|
||||
storeData := FileStoreData{
|
||||
Sessions: s.sessions,
|
||||
Requests: s.requests,
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(storeData, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal store: %w", err)
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0700); err != nil {
|
||||
return fmt.Errorf("failed to create directory: %w", err)
|
||||
}
|
||||
|
||||
// Write with restrictive permissions
|
||||
if err := os.WriteFile(s.path, data, 0600); err != nil {
|
||||
return fmt.Errorf("failed to write store: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// makeSessionKey creates a composite key for session storage
|
||||
func makeSessionKey(did, sessionID string) string {
|
||||
return fmt.Sprintf("%s:%s", did, sessionID)
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SessionClaims represents the data stored in a session token
|
||||
type SessionClaims struct {
|
||||
DID string `json:"did"`
|
||||
Handle string `json:"handle"`
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// Manager handles session token creation and validation
|
||||
type Manager struct {
|
||||
secret []byte
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
// NewManager creates a new session manager
|
||||
func NewManager(secret []byte, ttl time.Duration) *Manager {
|
||||
return &Manager{
|
||||
secret: secret,
|
||||
ttl: ttl,
|
||||
}
|
||||
}
|
||||
|
||||
// NewManagerWithRandomSecret creates a session manager with a random secret
|
||||
func NewManagerWithRandomSecret(ttl time.Duration) (*Manager, error) {
|
||||
secret := make([]byte, 32)
|
||||
if _, err := rand.Read(secret); err != nil {
|
||||
return nil, fmt.Errorf("failed to generate secret: %w", err)
|
||||
}
|
||||
return NewManager(secret, ttl), nil
|
||||
}
|
||||
|
||||
// NewManagerWithPersistentSecret creates a session manager with a persistent secret
|
||||
// The secret is stored at secretPath and reused across restarts
|
||||
func NewManagerWithPersistentSecret(secretPath string, ttl time.Duration) (*Manager, error) {
|
||||
var secret []byte
|
||||
|
||||
// Try to load existing secret
|
||||
if data, err := os.ReadFile(secretPath); err == nil {
|
||||
secret = data
|
||||
fmt.Printf("Loaded existing session secret from %s\n", secretPath)
|
||||
} else if os.IsNotExist(err) {
|
||||
// Generate new secret
|
||||
secret = make([]byte, 32)
|
||||
if _, err := rand.Read(secret); err != nil {
|
||||
return nil, fmt.Errorf("failed to generate secret: %w", err)
|
||||
}
|
||||
|
||||
// Save secret for future restarts
|
||||
if err := os.WriteFile(secretPath, secret, 0600); err != nil {
|
||||
return nil, fmt.Errorf("failed to save secret: %w", err)
|
||||
}
|
||||
fmt.Printf("Generated and saved new session secret to %s\n", secretPath)
|
||||
} else {
|
||||
return nil, fmt.Errorf("failed to read secret file: %w", err)
|
||||
}
|
||||
|
||||
return NewManager(secret, ttl), nil
|
||||
}
|
||||
|
||||
// Create generates a new session token for a DID
|
||||
func (m *Manager) Create(did, handle string) (string, error) {
|
||||
now := time.Now()
|
||||
claims := SessionClaims{
|
||||
DID: did,
|
||||
Handle: handle,
|
||||
IssuedAt: now,
|
||||
ExpiresAt: now.Add(m.ttl),
|
||||
}
|
||||
|
||||
// Marshal claims to JSON
|
||||
claimsJSON, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal claims: %w", err)
|
||||
}
|
||||
|
||||
// Base64 encode claims
|
||||
claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON)
|
||||
|
||||
// Generate HMAC signature
|
||||
sig := m.sign(claimsB64)
|
||||
sigB64 := base64.RawURLEncoding.EncodeToString(sig)
|
||||
|
||||
// Token format: <claims>.<signature>
|
||||
token := claimsB64 + "." + sigB64
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// Validate validates a session token and returns the claims
|
||||
func (m *Manager) Validate(token string) (*SessionClaims, error) {
|
||||
// Split token into claims and signature
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 2 {
|
||||
return nil, fmt.Errorf("invalid token format")
|
||||
}
|
||||
|
||||
claimsB64 := parts[0]
|
||||
sigB64 := parts[1]
|
||||
|
||||
// Verify signature
|
||||
expectedSig := m.sign(claimsB64)
|
||||
providedSig, err := base64.RawURLEncoding.DecodeString(sigB64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid signature encoding: %w", err)
|
||||
}
|
||||
|
||||
if !hmac.Equal(expectedSig, providedSig) {
|
||||
return nil, fmt.Errorf("invalid signature")
|
||||
}
|
||||
|
||||
// Decode claims
|
||||
claimsJSON, err := base64.RawURLEncoding.DecodeString(claimsB64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid claims encoding: %w", err)
|
||||
}
|
||||
|
||||
var claims SessionClaims
|
||||
if err := json.Unmarshal(claimsJSON, &claims); err != nil {
|
||||
return nil, fmt.Errorf("invalid claims format: %w", err)
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if time.Now().After(claims.ExpiresAt) {
|
||||
return nil, fmt.Errorf("token expired")
|
||||
}
|
||||
|
||||
return &claims, nil
|
||||
}
|
||||
|
||||
// sign generates HMAC-SHA256 signature for data
|
||||
func (m *Manager) sign(data string) []byte {
|
||||
h := hmac.New(sha256.New, m.secret)
|
||||
h.Write([]byte(data))
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
// GetDID extracts the DID from a token without full validation
|
||||
// Useful for logging/debugging
|
||||
func (m *Manager) GetDID(token string) (string, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 2 {
|
||||
return "", fmt.Errorf("invalid token format")
|
||||
}
|
||||
|
||||
claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid claims encoding: %w", err)
|
||||
}
|
||||
|
||||
var claims SessionClaims
|
||||
if err := json.Unmarshal(claimsJSON, &claims); err != nil {
|
||||
return "", fmt.Errorf("invalid claims format: %w", err)
|
||||
}
|
||||
|
||||
return claims.DID, nil
|
||||
}
|
||||
+43
-28
@@ -7,26 +7,29 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bluesky-social/indigo/atproto/identity"
|
||||
"github.com/bluesky-social/indigo/atproto/syntax"
|
||||
|
||||
"atcr.io/pkg/appview/apikey"
|
||||
mainAtproto "atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/auth"
|
||||
"atcr.io/pkg/auth/atproto"
|
||||
"atcr.io/pkg/auth/session"
|
||||
)
|
||||
|
||||
// Handler handles /auth/token requests
|
||||
type Handler struct {
|
||||
issuer *Issuer
|
||||
validator *atproto.SessionValidator
|
||||
sessionManager *session.Manager // For validating session tokens
|
||||
apiKeyStore *apikey.Store // For validating API keys
|
||||
defaultHoldEndpoint string
|
||||
}
|
||||
|
||||
// NewHandler creates a new token handler
|
||||
func NewHandler(issuer *Issuer, sessionManager *session.Manager, defaultHoldEndpoint string) *Handler {
|
||||
func NewHandler(issuer *Issuer, apiKeyStore *apikey.Store, defaultHoldEndpoint string) *Handler {
|
||||
return &Handler{
|
||||
issuer: issuer,
|
||||
validator: atproto.NewSessionValidator(),
|
||||
sessionManager: sessionManager,
|
||||
apiKeyStore: apiKeyStore,
|
||||
defaultHoldEndpoint: defaultHoldEndpoint,
|
||||
}
|
||||
}
|
||||
@@ -80,19 +83,25 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
var handle string
|
||||
var accessToken string
|
||||
|
||||
// Try to validate as session token first (our OAuth flow)
|
||||
// Session tokens have format: <base64_claims>.<base64_signature>
|
||||
sessionClaims, sessionErr := h.sessionManager.Validate(password)
|
||||
if sessionErr == nil {
|
||||
// Successfully validated as session token
|
||||
did = sessionClaims.DID
|
||||
handle = sessionClaims.Handle
|
||||
fmt.Printf("DEBUG [token/handler]: Session token validated for DID=%s, handle=%s\n", did, handle)
|
||||
// For session tokens, we don't have a PDS access token here
|
||||
// The registry will use OAuth refresh tokens to get one when needed
|
||||
// 1. Check if it's an API key (starts with "atcr_")
|
||||
if strings.HasPrefix(password, "atcr_") {
|
||||
apiKey, err := h.apiKeyStore.Validate(password)
|
||||
if err != nil {
|
||||
fmt.Printf("DEBUG [token/handler]: API key validation failed: %v\n", err)
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="ATCR Registry"`)
|
||||
http.Error(w, "authentication failed", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
did = apiKey.DID
|
||||
handle = apiKey.Handle
|
||||
fmt.Printf("DEBUG [token/handler]: API key validated for DID=%s, handle=%s\n", did, handle)
|
||||
|
||||
// API key is linked to OAuth session
|
||||
// OAuth refresher will provide access token when needed via middleware
|
||||
} else {
|
||||
// Not a session token, try app password (Basic Auth flow)
|
||||
fmt.Printf("DEBUG [token/handler]: Not a session token, trying app password for %s\n", username)
|
||||
// 2. Try app password (direct PDS authentication)
|
||||
fmt.Printf("DEBUG [token/handler]: Not an API key, trying app password for %s\n", username)
|
||||
did, handle, accessToken, err = h.validator.CreateSessionAndGetToken(r.Context(), username, password)
|
||||
if err != nil {
|
||||
fmt.Printf("DEBUG [token/handler]: App password validation failed: %v\n", err)
|
||||
@@ -110,19 +119,25 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Ensure user profile exists (creates with default hold if needed)
|
||||
// Resolve PDS endpoint for profile management
|
||||
resolver := mainAtproto.NewResolver()
|
||||
_, pdsEndpoint, err := resolver.ResolveIdentity(r.Context(), username)
|
||||
if err != nil {
|
||||
// Log error but don't fail auth - profile management is not critical
|
||||
fmt.Printf("WARNING: failed to resolve PDS for profile management: %v\n", err)
|
||||
} else {
|
||||
// Create ATProto client with validated token
|
||||
atprotoClient := mainAtproto.NewClient(pdsEndpoint, did, accessToken)
|
||||
|
||||
// Ensure profile exists (will create with default hold if not exists and default is configured)
|
||||
if err := mainAtproto.EnsureProfile(r.Context(), atprotoClient, h.defaultHoldEndpoint); err != nil {
|
||||
directory := identity.DefaultDirectory()
|
||||
atID, err := syntax.ParseAtIdentifier(username)
|
||||
if err == nil {
|
||||
ident, err := directory.Lookup(r.Context(), *atID)
|
||||
if err != nil {
|
||||
// Log error but don't fail auth - profile management is not critical
|
||||
fmt.Printf("WARNING: failed to ensure profile for %s: %v\n", did, err)
|
||||
fmt.Printf("WARNING: failed to resolve PDS for profile management: %v\n", err)
|
||||
} else {
|
||||
pdsEndpoint := ident.PDSEndpoint()
|
||||
if pdsEndpoint != "" {
|
||||
// Create ATProto client with validated token
|
||||
atprotoClient := mainAtproto.NewClient(pdsEndpoint, did, accessToken)
|
||||
|
||||
// Ensure profile exists (will create with default hold if not exists and default is configured)
|
||||
if err := mainAtproto.EnsureProfile(r.Context(), atprotoClient, h.defaultHoldEndpoint); err != nil {
|
||||
// Log error but don't fail auth - profile management is not critical
|
||||
fmt.Printf("WARNING: failed to ensure profile for %s: %v\n", did, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user