A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
80
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor oauth to use indigo

+446 -1549
+40 -33
cmd/hold/main.go
··· 18 18 19 19 "atcr.io/pkg/atproto" 20 20 "atcr.io/pkg/auth/oauth" 21 + indigooauth "github.com/bluesky-social/indigo/atproto/auth/oauth" 21 22 22 23 // Import storage drivers 23 24 _ "github.com/distribution/distribution/v3/registry/storage/driver/filesystem" ··· 610 611 mux.HandleFunc("/move", service.HandleMove) 611 612 612 613 // OAuth client metadata endpoint for ATProto OAuth 613 - clientID := cfg.Server.PublicURL + "/client-metadata.json" 614 - clientMetadata := oauth.NewClientMetadata(clientID, []string{cfg.Server.PublicURL + "/oauth/callback"}) 615 - clientMetadata.ClientName = "ATCR Hold Service" 616 - clientMetadata.ApplicationType = "web" // Changed from "native" since this is a web service 617 - mux.HandleFunc("/client-metadata.json", oauth.ServeMetadata(clientMetadata)) 614 + // The hold service serves its metadata at /client-metadata.json 615 + // This is referenced by its client ID URL 616 + mux.HandleFunc("/client-metadata.json", func(w http.ResponseWriter, r *http.Request) { 617 + // Create a temporary config to generate metadata (indigo provides this) 618 + redirectURI := cfg.Server.PublicURL + "/oauth/callback" 619 + clientID := cfg.Server.PublicURL + "/client-metadata.json" 620 + scopes := []string{"atproto"} // Hold service uses default scopes 621 + 622 + config := indigooauth.NewPublicConfig(clientID, redirectURI, scopes) 623 + metadata := config.ClientMetadata() 624 + 625 + // Serve as JSON 626 + w.Header().Set("Content-Type", "application/json") 627 + w.Header().Set("Access-Control-Allow-Origin", "*") 628 + json.NewEncoder(w).Encode(metadata) 629 + }) 618 630 mux.HandleFunc("/blobs/", func(w http.ResponseWriter, r *http.Request) { 619 631 switch r.Method { 620 632 case http.MethodGet, http.MethodHead: ··· 884 896 885 897 // Run interactive OAuth flow with persistent server 886 898 ctx := context.Background() 887 - result, err := oauth.RunInteractiveFlow( 899 + 900 + // Note: holdScopes are ignored for now as indigo uses default scopes 901 + // TODO: Enhance indigo App to support custom scopes if needed 902 + _ = holdScopes 903 + 904 + result, err := oauth.InteractiveFlowWithCallback( 888 905 ctx, 889 - oauth.InteractiveFlowConfig{ 890 - BaseURL: baseURL, 891 - Handle: handle, 892 - Scopes: holdScopes, 906 + baseURL, 907 + handle, 908 + nil, // scopes (not used - indigo uses defaults) 909 + func(handler http.HandlerFunc) error { 910 + // Register callback on existing server (persistent server pattern) 911 + http.HandleFunc("/auth/oauth/callback", handler) 912 + return nil 893 913 }, 894 - func(authURL string, handler *oauth.CallbackHandler, metadata *oauth.ClientMetadata) error { 895 - // First call (authURL empty): register callback handler 896 - if authURL == "" { 897 - // Register callback on existing server (persistent server pattern) 898 - // Note: metadata is not used here since hold service serves it separately on main server 899 - http.HandleFunc("/auth/oauth/callback", handler.ServeHTTP) 900 - return nil 901 - } 902 - 903 - // Second call (authURL populated): display URL 904 - // Print the OAuth URL for user to visit (hold-specific formatting) 914 + func(authURL string) error { 915 + // Display OAuth URL for user to visit 905 916 log.Print("\n" + strings.Repeat("=", 80)) 906 917 log.Printf("OAUTH AUTHORIZATION REQUIRED") 907 918 log.Print(strings.Repeat("=", 80)) ··· 909 920 log.Printf(" %s\n", authURL) 910 921 log.Printf("Waiting for authorization...") 911 922 log.Print(strings.Repeat("=", 80) + "\n") 912 - 913 923 return nil 914 924 }, 915 925 ) ··· 917 927 return err 918 928 } 919 929 920 - log.Printf("Authorization received, exchanging code for token...") 921 - token := result.Token 922 - 923 - log.Printf("OAuth token obtained successfully") 930 + log.Printf("Authorization received!") 931 + log.Printf("OAuth session obtained successfully") 924 932 log.Printf("DID: %s", did) 925 933 log.Printf("PDS: %s", pdsEndpoint) 926 934 927 - // Now register with the token using DPoP 928 - // Create ATProto client with DPoP transport from OAuth client 929 - dpopKey := result.Client.DPoPKey() 930 - dpopTransport := oauth.NewDPoPTransport(http.DefaultTransport, dpopKey) 931 - // Set the access token in the transport for "ath" claim computation 932 - dpopTransport.SetAccessToken(token.AccessToken) 933 - client := atproto.NewClientWithDPoP(pdsEndpoint, did, token.AccessToken, dpopKey, dpopTransport) 935 + // Extract access token and HTTP client from session 936 + accessToken, _ := result.Session.GetHostAccessData() 937 + httpClient := result.Session.APIClient().Client 938 + 939 + // Create ATProto client with indigo's DPoP-configured HTTP client 940 + client := atproto.NewClientWithHTTPClient(pdsEndpoint, did, accessToken, httpClient) 934 941 935 942 return s.registerWithClient(publicURL, did, client) 936 943 }
+18 -21
cmd/registry/serve.go
··· 69 69 // Initialize OAuth components 70 70 fmt.Println("Initializing OAuth components...") 71 71 72 - // 1. Create refresh token storage 72 + // 1. Create OAuth session storage 73 73 // Allow override via environment variable for Docker deployments 74 74 storagePath := os.Getenv("ATCR_TOKEN_STORAGE_PATH") 75 75 if storagePath == "" { 76 76 var err error 77 - storagePath, err = oauth.GetDefaultPath() 77 + storagePath, err = oauth.GetDefaultStorePath() 78 78 if err != nil { 79 79 return fmt.Errorf("failed to get storage path: %w", err) 80 80 } ··· 86 86 return fmt.Errorf("failed to create storage directory: %w", err) 87 87 } 88 88 89 - fmt.Printf("Using token storage path: %s\n", storagePath) 89 + fmt.Printf("Using OAuth session storage path: %s\n", storagePath) 90 90 91 - refreshStorage, err := oauth.NewRefreshTokenStorage(storagePath) 91 + oauthStore, err := oauth.NewFileStore(storagePath) 92 92 if err != nil { 93 - return fmt.Errorf("failed to create refresh token storage: %w", err) 93 + return fmt.Errorf("failed to create OAuth store: %w", err) 94 94 } 95 95 96 96 // 2. Create session manager with 30-day TTL ··· 119 119 120 120 fmt.Printf("DEBUG: Base URL for OAuth: %s\n", baseURL) 121 121 122 - // 4. Create refresher 123 - refresher := oauth.NewRefresher(refreshStorage, baseURL) 124 - // Start cleanup routine (runs every hour) 125 - refresher.StartCleanupRoutine(1 * time.Hour) 122 + // 4. Create OAuth app (indigo client) 123 + oauthApp, err := oauth.NewApp(baseURL, oauthStore) 124 + if err != nil { 125 + return fmt.Errorf("failed to create OAuth app: %w", err) 126 + } 127 + 128 + // 5. Create refresher 129 + refresher := oauth.NewRefresher(oauthApp) 126 130 127 - // 5. Set global refresher for middleware 131 + // 6. Set global refresher for middleware 128 132 middleware.SetGlobalRefresher(refresher) 129 133 130 - // 6. Initialize UI components (get session store for OAuth integration) 134 + // 7. Initialize UI components (get session store for OAuth integration) 131 135 uiDatabase, uiSessionStore, uiTemplates, uiRouter := initializeUI(config, refresher, baseURL) 132 136 133 - // 7. Create OAuth server 134 - oauthServer := oauth.NewServer(refreshStorage, sessionManager, baseURL) 137 + // 8. Create OAuth server 138 + oauthServer := oauth.NewServer(oauthApp, sessionManager) 135 139 // Connect server to refresher for cache invalidation 136 140 oauthServer.SetRefresher(refresher) 137 141 // Connect UI session store for web login ··· 181 185 mux.HandleFunc("/auth/oauth/authorize", oauthServer.ServeAuthorize) 182 186 mux.HandleFunc("/auth/oauth/callback", oauthServer.ServeCallback) 183 187 184 - // Start OAuth server cleanup routine 185 - go func() { 186 - ticker := time.NewTicker(10 * time.Minute) 187 - defer ticker.Stop() 188 - for range ticker.C { 189 - oauthServer.CleanupExpiredStates() 190 - } 191 - }() 188 + // Note: Indigo handles OAuth state cleanup internally via its store 192 189 193 190 // Mount auth endpoints if enabled 194 191 if issuer != nil {
+9 -3
go.mod
··· 3 3 go 1.24.7 4 4 5 5 require ( 6 - authelia.com/client/oauth2 v0.0.0-20250405043315-6378a9b2a190 7 - github.com/AxisCommunications/go-dpop v1.1.2 6 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e 8 7 github.com/distribution/distribution/v3 v3.0.0 9 8 github.com/distribution/reference v0.6.0 10 9 github.com/golang-jwt/jwt/v5 v5.2.2 11 - github.com/google/uuid v1.6.0 12 10 github.com/gorilla/mux v1.8.1 13 11 github.com/gorilla/websocket v1.5.3 14 12 github.com/klauspost/compress v1.18.0 ··· 21 19 github.com/aws/aws-sdk-go v1.55.5 // indirect 22 20 github.com/beorn7/perks v1.0.1 // indirect 23 21 github.com/bshuster-repo/logrus-logstash-hook v1.0.0 // indirect 22 + github.com/carlmjohnson/versioninfo v0.22.5 // indirect 24 23 github.com/cenkalti/backoff/v4 v4.3.0 // indirect 25 24 github.com/cespare/xxhash/v2 v2.3.0 // indirect 26 25 github.com/coreos/go-systemd/v22 v22.5.0 // indirect ··· 32 31 github.com/go-jose/go-jose/v4 v4.1.2 // indirect 33 32 github.com/go-logr/logr v1.4.2 // indirect 34 33 github.com/go-logr/stdr v1.2.2 // indirect 34 + github.com/google/go-cmp v0.7.0 // indirect 35 + github.com/google/go-querystring v1.1.0 // indirect 36 + github.com/google/uuid v1.6.0 // indirect 35 37 github.com/gorilla/handlers v1.5.2 // indirect 36 38 github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0 // indirect 37 39 github.com/hashicorp/golang-lru/arc/v2 v2.0.6 // indirect 38 40 github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 39 41 github.com/inconshreveable/mousetrap v1.1.0 // indirect 40 42 github.com/jmespath/go-jmespath v0.4.0 // indirect 43 + github.com/mr-tron/base58 v1.2.0 // indirect 41 44 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 42 45 github.com/opencontainers/image-spec v1.1.0 // indirect 43 46 github.com/prometheus/client_golang v1.20.5 // indirect ··· 49 52 github.com/redis/go-redis/v9 v9.7.3 // indirect 50 53 github.com/sirupsen/logrus v1.9.3 // indirect 51 54 github.com/spf13/pflag v1.0.5 // indirect 55 + gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 56 + gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 52 57 go.opentelemetry.io/contrib/bridges/prometheus v0.57.0 // indirect 53 58 go.opentelemetry.io/contrib/exporters/autoexport v0.57.0 // indirect 54 59 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0 // indirect ··· 76 81 golang.org/x/sync v0.15.0 // indirect 77 82 golang.org/x/sys v0.33.0 // indirect 78 83 golang.org/x/text v0.26.0 // indirect 84 + golang.org/x/time v0.6.0 // indirect 79 85 google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28 // indirect 80 86 google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28 // indirect 81 87 google.golang.org/grpc v1.68.0 // indirect
+84 -4
go.sum
··· 1 - authelia.com/client/oauth2 v0.0.0-20250405043315-6378a9b2a190 h1:5YfShMnyeIOFX5C1I7i6YrEpIfQCeeDBFTjau/iLfVU= 2 - authelia.com/client/oauth2 v0.0.0-20250405043315-6378a9b2a190/go.mod h1:f0e/AQgp3qHJ2gSVnCheQZ4gTCm7BHasGpWrce36n9Q= 3 1 github.com/AdaLogics/go-fuzz-headers v0.0.0-20221103172237-443f56ff4ba8 h1:d+pBUmsteW5tM87xmVXHZ4+LibHRFn40SPAoZJOg2ak= 4 2 github.com/AdaLogics/go-fuzz-headers v0.0.0-20221103172237-443f56ff4ba8/go.mod h1:i9fr2JpcEcY/IHEvzCM3qXUZYOQHgR89dt4es1CgMhc= 5 - github.com/AxisCommunications/go-dpop v1.1.2 h1:ICgk/8crE7pmWo5MML1kzyHF9wVJg6a78fW7rKxFavg= 6 - github.com/AxisCommunications/go-dpop v1.1.2/go.mod h1:bGUXY9Wd4mnd+XUrOYZr358J2f6z9QO/dLhL1SsiD+0= 7 3 github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 8 4 github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= 9 5 github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU= ··· 12 8 github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= 13 9 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 14 10 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 11 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e h1:IutKPwmbU0LrYqw03EuwJtMdAe67rDTrL1U8S8dicRU= 12 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e/go.mod h1:n6QE1NDPFoi7PRbMUZmc2y7FibCqiVU4ePpsvhHUBR8= 15 13 github.com/bshuster-repo/logrus-logstash-hook v1.0.0 h1:e+C0SB5R1pu//O4MQ3f9cFuPGoOVeF2fE4Og9otCc70= 16 14 github.com/bshuster-repo/logrus-logstash-hook v1.0.0/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk= 17 15 github.com/bsm/ginkgo/v2 v2.7.0/go.mod h1:AiKlXPm7ItEHNc/2+OkrNG4E0ITzojb9/xWzvQ9XZ9w= ··· 20 18 github.com/bsm/gomega v1.26.0/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= 21 19 github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= 22 20 github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= 21 + github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc= 22 + github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8= 23 23 github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= 24 24 github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 25 25 github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= ··· 60 60 github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 61 61 github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 62 62 github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= 63 + github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 64 + github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 63 65 github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= 64 66 github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 65 67 github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= ··· 68 70 github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 69 71 github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 70 72 github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 73 + github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 71 74 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 72 75 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 76 + github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 77 + github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 73 78 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 74 79 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 75 80 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= ··· 81 86 github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 82 87 github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0 h1:ad0vkEBuk23VJzZR9nkLVG0YAoN9coASF1GusYX6AlU= 83 88 github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0/go.mod h1:igFoXX2ELCW06bol23DWPB5BEWfZISOzSP5K2sbLea0= 89 + github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 90 + github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 91 + github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M= 92 + github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= 93 + github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= 94 + github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 84 95 github.com/hashicorp/golang-lru/arc/v2 v2.0.6 h1:4NU7uP5vSoK6TbaMj3NtY478TTAWLso/vL1gpNrInHg= 85 96 github.com/hashicorp/golang-lru/arc/v2 v2.0.6/go.mod h1:cfdDIX05DWvYV6/shsxDfa/OVcRieOt+q4FnM8x+Xno= 86 97 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 87 98 github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 88 99 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 89 100 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 101 + github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs= 102 + github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0= 103 + github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs= 104 + github.com/ipfs/go-block-format v0.2.0/go.mod h1:+jpL11nFx5A/SPpsoBn6Bzkra/zaArfSmsknbPMYgzM= 105 + github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s= 106 + github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk= 107 + github.com/ipfs/go-datastore v0.6.0 h1:JKyz+Gvz1QEZw0LsX1IBn+JFCJQH4SJVFtM4uWU0Myk= 108 + github.com/ipfs/go-datastore v0.6.0/go.mod h1:rt5M3nNbSO/8q1t4LNkLyUwRs8HupMeN/8O4Vn9YAT8= 109 + github.com/ipfs/go-ipfs-blockstore v1.3.1 h1:cEI9ci7V0sRNivqaOr0elDsamxXFxJMMMy7PTTDQNsQ= 110 + github.com/ipfs/go-ipfs-blockstore v1.3.1/go.mod h1:KgtZyc9fq+P2xJUiCAzbRdhhqJHvsw8u2Dlqy2MyRTE= 111 + github.com/ipfs/go-ipfs-ds-help v1.1.1 h1:B5UJOH52IbcfS56+Ul+sv8jnIV10lbjLF5eOO0C66Nw= 112 + github.com/ipfs/go-ipfs-ds-help v1.1.1/go.mod h1:75vrVCkSdSFidJscs8n4W+77AtTpCIAdDGAwjitJMIo= 113 + github.com/ipfs/go-ipfs-util v0.0.3 h1:2RFdGez6bu2ZlZdI+rWfIdbQb1KudQp3VGwPtdNCmE0= 114 + github.com/ipfs/go-ipfs-util v0.0.3/go.mod h1:LHzG1a0Ig4G+iZ26UUOMjHd+lfM84LZCrn17xAKWBvs= 115 + github.com/ipfs/go-ipld-cbor v0.1.0 h1:dx0nS0kILVivGhfWuB6dUpMa/LAwElHPw1yOGYopoYs= 116 + github.com/ipfs/go-ipld-cbor v0.1.0/go.mod h1:U2aYlmVrJr2wsUBU67K4KgepApSZddGRDWBYR0H4sCk= 117 + github.com/ipfs/go-ipld-format v0.6.0 h1:VEJlA2kQ3LqFSIm5Vu6eIlSxD/Ze90xtc4Meten1F5U= 118 + github.com/ipfs/go-ipld-format v0.6.0/go.mod h1:g4QVMTn3marU3qXchwjpKPKgJv+zF+OlaKMyhJ4LHPg= 119 + github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8= 120 + github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo= 121 + github.com/ipfs/go-log/v2 v2.5.1 h1:1XdUzF7048prq4aBjDQQ4SL5RxftpRGdXhNRwKSAlcY= 122 + github.com/ipfs/go-log/v2 v2.5.1/go.mod h1:prSpmC1Gpllc9UYWxDiZDreBYw7zp4Iqp1kOLU9U5UI= 123 + github.com/ipfs/go-metrics-interface v0.0.1 h1:j+cpbjYvu4R8zbleSs36gvB7jR+wsL2fGD6n0jO4kdg= 124 + github.com/ipfs/go-metrics-interface v0.0.1/go.mod h1:6s6euYU4zowdslK0GKHmqaIZ3j/b/tL7HTWtJ4VPgWY= 125 + github.com/jbenet/goprocess v0.1.4 h1:DRGOFReOMqqDNXwW70QkacFW0YN9QnwLV0Vqk+3oU0o= 126 + github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4= 90 127 github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= 91 128 github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 92 129 github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= ··· 96 133 github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= 97 134 github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 98 135 github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 136 + github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= 137 + github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= 99 138 github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 100 139 github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= 101 140 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= ··· 104 143 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 105 144 github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 106 145 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 146 + github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 147 + github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 107 148 github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= 108 149 github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 109 150 github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= 151 + github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= 152 + github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= 110 153 github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 111 154 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 112 155 github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 113 156 github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 157 + github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= 158 + github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= 159 + github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= 160 + github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= 161 + github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= 162 + github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4= 163 + github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= 164 + github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= 165 + github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= 166 + github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= 167 + github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= 168 + github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU= 114 169 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 115 170 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 116 171 github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= ··· 118 173 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 119 174 github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= 120 175 github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= 176 + github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= 177 + github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= 121 178 github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 122 179 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 123 180 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 181 + github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0= 182 + github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw= 124 183 github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= 125 184 github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= 126 185 github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g= ··· 152 211 github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= 153 212 github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 154 213 github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 214 + github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= 215 + github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 155 216 github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= 156 217 github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= 157 218 github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= ··· 163 224 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 164 225 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 165 226 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 227 + github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4= 228 + github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 229 + gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= 230 + gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 231 + gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= 232 + gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= 166 233 go.opentelemetry.io/contrib/bridges/prometheus v0.57.0 h1:UW0+QyeyBVhn+COBec3nGhfnFe5lwB0ic1JBVjzhk0w= 167 234 go.opentelemetry.io/contrib/bridges/prometheus v0.57.0/go.mod h1:ppciCHRLsyCio54qbzQv0E4Jyth/fLWDTJYfvWpcSVk= 168 235 go.opentelemetry.io/contrib/exporters/autoexport v0.57.0 h1:jmTVJ86dP60C01K3slFQa2NQ/Aoi7zA+wy7vMOKD9H4= ··· 207 274 go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8= 208 275 go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= 209 276 go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= 277 + go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= 278 + go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= 210 279 go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 211 280 go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 281 + go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 282 + go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 283 + go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= 284 + go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= 212 285 golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 213 286 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 214 287 golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= ··· 231 304 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 232 305 golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= 233 306 golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= 307 + golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= 308 + golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 309 + golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 310 + golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= 311 + golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 234 312 google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28 h1:M0KvPgPmDZHPlbRbaNU1APr28TvwvvdUPlSv7PUvy8g= 235 313 google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28/go.mod h1:dguCy7UOdZhTvLzDyt15+rOrawrpM4q7DD9dQ1P11P4= 236 314 google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28 h1:XVhgTWWV3kGQlwJHR3upFWZeTsei6Oks1apkZSeonIE= ··· 250 328 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 251 329 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 252 330 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 331 + lukechampine.com/blake3 v1.2.1 h1:YuqqRuaqsGV71BV/nm9xlI0MKUv4QC54jQnBChWbGnI= 332 + lukechampine.com/blake3 v1.2.1/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k=
+18 -10
pkg/appview/handlers/settings.go
··· 25 25 return 26 26 } 27 27 28 - // Get access token and DPoP transport for the user 29 - accessToken, _, dpopTransport, err := h.Refresher.GetAccessToken(r.Context(), user.DID) 28 + // Get OAuth session for the user 29 + session, err := h.Refresher.GetSession(r.Context(), user.DID) 30 30 if err != nil { 31 - http.Error(w, "Failed to get access token: "+err.Error(), http.StatusInternalServerError) 31 + http.Error(w, "Failed to get session: "+err.Error(), http.StatusInternalServerError) 32 32 return 33 33 } 34 34 35 - // Create ATProto client with DPoP transport 36 - client := atproto.NewClientWithDPoP(user.PDSEndpoint, user.DID, accessToken, nil, dpopTransport) 35 + // Extract access token and HTTP client from session 36 + accessToken, _ := session.GetHostAccessData() 37 + httpClient := session.APIClient().Client 38 + 39 + // Create ATProto client with indigo's DPoP-configured HTTP client 40 + client := atproto.NewClientWithHTTPClient(user.PDSEndpoint, user.DID, accessToken, httpClient) 37 41 38 42 // Fetch sailor profile 39 43 profile, err := atproto.GetProfile(r.Context(), client) ··· 86 90 87 91 holdEndpoint := r.FormValue("hold_endpoint") 88 92 89 - // Get access token and DPoP transport for the user 90 - accessToken, _, dpopTransport, err := h.Refresher.GetAccessToken(r.Context(), user.DID) 93 + // Get OAuth session for the user 94 + session, err := h.Refresher.GetSession(r.Context(), user.DID) 91 95 if err != nil { 92 - http.Error(w, "Failed to get access token: "+err.Error(), http.StatusInternalServerError) 96 + http.Error(w, "Failed to get session: "+err.Error(), http.StatusInternalServerError) 93 97 return 94 98 } 95 99 96 - // Create ATProto client with DPoP transport 97 - client := atproto.NewClientWithDPoP(user.PDSEndpoint, user.DID, accessToken, nil, dpopTransport) 100 + // Extract access token and HTTP client from session 101 + accessToken, _ := session.GetHostAccessData() 102 + httpClient := session.APIClient().Client 103 + 104 + // Create ATProto client with indigo's DPoP-configured HTTP client 105 + client := atproto.NewClientWithHTTPClient(user.PDSEndpoint, user.DID, accessToken, httpClient) 98 106 99 107 // Fetch existing profile or create new one 100 108 profile, err := atproto.GetProfile(r.Context(), client)
+14
pkg/atproto/client.go
··· 44 44 } 45 45 } 46 46 47 + // NewClientWithHTTPClient creates a new ATProto client with a pre-configured HTTP client 48 + // This is useful when using indigo's OAuth session which provides a DPoP-configured client 49 + // The access token will be used for Authorization headers, while the HTTP client 50 + // handles transport-level concerns (like DPoP proofs) 51 + func NewClientWithHTTPClient(pdsEndpoint, did, accessToken string, httpClient *http.Client) *Client { 52 + return &Client{ 53 + pdsEndpoint: pdsEndpoint, 54 + did: did, 55 + accessToken: accessToken, 56 + httpClient: httpClient, 57 + useDPoP: true, // Assume DPoP when using custom client 58 + } 59 + } 60 + 47 61 // authHeader returns the appropriate Authorization header value 48 62 func (c *Client) authHeader() string { 49 63 if c.useDPoP {
-269
pkg/auth/oauth/callback.go
··· 1 - package oauth 2 - 3 - import ( 4 - "crypto/rand" 5 - "encoding/base64" 6 - "fmt" 7 - "net" 8 - "net/http" 9 - "net/url" 10 - "os/exec" 11 - "runtime" 12 - "strings" 13 - "time" 14 - ) 15 - 16 - // CallbackHandler manages OAuth callback handling 17 - type CallbackHandler struct { 18 - state string 19 - codeChan chan string 20 - errChan chan error 21 - } 22 - 23 - // NewCallbackHandler creates a new callback handler 24 - func NewCallbackHandler(state string) *CallbackHandler { 25 - return &CallbackHandler{ 26 - state: state, 27 - codeChan: make(chan string, 1), 28 - errChan: make(chan error, 1), 29 - } 30 - } 31 - 32 - // ServeHTTP handles the OAuth callback request 33 - func (h *CallbackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 34 - code := r.URL.Query().Get("code") 35 - receivedState := r.URL.Query().Get("state") 36 - errorParam := r.URL.Query().Get("error") 37 - 38 - // Validate state parameter 39 - if receivedState != h.state { 40 - h.errChan <- fmt.Errorf("invalid state parameter") 41 - http.Error(w, "Invalid state", http.StatusBadRequest) 42 - return 43 - } 44 - 45 - // Check for OAuth error 46 - if errorParam != "" { 47 - h.errChan <- fmt.Errorf("OAuth error: %s (%s)", 48 - errorParam, 49 - r.URL.Query().Get("error_description")) 50 - http.Error(w, "Authorization failed", http.StatusBadRequest) 51 - return 52 - } 53 - 54 - // Validate code is present 55 - if code == "" { 56 - h.errChan <- fmt.Errorf("no authorization code received") 57 - http.Error(w, "No code provided", http.StatusBadRequest) 58 - return 59 - } 60 - 61 - // Send success response to browser 62 - RenderSuccessHTML(w) 63 - 64 - // Send code to waiting goroutine 65 - select { 66 - case h.codeChan <- code: 67 - default: 68 - // Channel already has a value or nobody is listening 69 - } 70 - } 71 - 72 - // WaitForCode waits for the OAuth callback to complete 73 - func (h *CallbackHandler) WaitForCode(timeout time.Duration) (string, error) { 74 - select { 75 - case code := <-h.codeChan: 76 - return code, nil 77 - case err := <-h.errChan: 78 - return "", err 79 - case <-time.After(timeout): 80 - return "", fmt.Errorf("OAuth timeout after %v", timeout) 81 - } 82 - } 83 - 84 - // GenerateState generates a random state parameter for OAuth 85 - func GenerateState() (string, error) { 86 - // Generate 32 random bytes 87 - bytes := make([]byte, 32) 88 - if _, err := rand.Read(bytes); err != nil { 89 - return "", fmt.Errorf("failed to generate random state: %w", err) 90 - } 91 - return base64.RawURLEncoding.EncodeToString(bytes), nil 92 - } 93 - 94 - // OpenBrowser opens the default browser to the given URL 95 - func OpenBrowser(url string) error { 96 - var cmd *exec.Cmd 97 - 98 - switch runtime.GOOS { 99 - case "darwin": 100 - cmd = exec.Command("open", url) 101 - case "linux": 102 - cmd = exec.Command("xdg-open", url) 103 - case "windows": 104 - cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) 105 - default: 106 - return fmt.Errorf("unsupported platform: %s", runtime.GOOS) 107 - } 108 - 109 - return cmd.Start() 110 - } 111 - 112 - // RenderSuccessHTML renders the OAuth success page 113 - func RenderSuccessHTML(w http.ResponseWriter) { 114 - w.Header().Set("Content-Type", "text/html; charset=utf-8") 115 - fmt.Fprintf(w, `<!DOCTYPE html> 116 - <html> 117 - <head> 118 - <meta charset="UTF-8"> 119 - <title>ATCR Authorization</title> 120 - <style> 121 - body { 122 - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; 123 - display: flex; 124 - justify-content: center; 125 - align-items: center; 126 - height: 100vh; 127 - margin: 0; 128 - background: linear-gradient(135deg, #667eea 0%%, #764ba2 100%%); 129 - } 130 - .container { 131 - background: white; 132 - padding: 3rem; 133 - border-radius: 1rem; 134 - box-shadow: 0 10px 40px rgba(0,0,0,0.2); 135 - text-align: center; 136 - max-width: 400px; 137 - } 138 - h1 { 139 - color: #2d3748; 140 - margin: 0 0 1rem 0; 141 - font-size: 2rem; 142 - } 143 - p { 144 - color: #718096; 145 - margin: 0; 146 - font-size: 1.1rem; 147 - } 148 - .checkmark { 149 - font-size: 4rem; 150 - color: #48bb78; 151 - margin-bottom: 1rem; 152 - } 153 - </style> 154 - </head> 155 - <body> 156 - <div class="container"> 157 - <div class="checkmark">✓</div> 158 - <h1>Authorization Successful!</h1> 159 - <p>You can close this window and return to the terminal.</p> 160 - </div> 161 - </body> 162 - </html>`) 163 - } 164 - 165 - // StartCallbackServer creates an ephemeral HTTP server for OAuth callbacks 166 - // This is useful for CLI tools that need a temporary OAuth endpoint 167 - // Derives the listen address and paths from the metadata's ClientID and RedirectURIs 168 - func StartCallbackServer(handler *CallbackHandler, metadata *ClientMetadata) (*http.Server, error) { 169 - if len(metadata.RedirectURIs) == 0 { 170 - return nil, fmt.Errorf("no redirect URIs in metadata") 171 - } 172 - 173 - // Parse redirect URI to extract listen address and callback path 174 - redirectURI := metadata.RedirectURIs[0] 175 - u, err := url.Parse(redirectURI) 176 - if err != nil { 177 - return nil, fmt.Errorf("failed to parse redirect URI: %w", err) 178 - } 179 - 180 - // Extract listen address (host:port) 181 - addr := u.Host 182 - callbackPath := u.Path 183 - 184 - mux := http.NewServeMux() 185 - 186 - // Check if this is a query-based client ID (localhost OAuth) 187 - isQueryBased := strings.HasPrefix(metadata.ClientID, "http://localhost?") 188 - 189 - var metadataPath string 190 - if !isQueryBased { 191 - // Metadata URL client ID - parse and serve metadata 192 - clientIDURL := metadata.ClientID 193 - if idx := strings.Index(clientIDURL, "?"); idx != -1 { 194 - clientIDURL = clientIDURL[:idx] 195 - } 196 - 197 - clientURL, err := url.Parse(clientIDURL) 198 - if err != nil { 199 - return nil, fmt.Errorf("failed to parse client ID: %w", err) 200 - } 201 - metadataPath = clientURL.Path 202 - 203 - // Serve client metadata at the path from ClientID 204 - mux.Handle(metadataPath, ServeMetadata(metadata)) 205 - } 206 - 207 - // Register OAuth callback handler at the path from RedirectURI 208 - mux.Handle(callbackPath, handler) 209 - 210 - server := &http.Server{ 211 - Addr: addr, 212 - Handler: mux, 213 - } 214 - 215 - go func() { 216 - if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { 217 - // Server error will be caught by WaitForCode timeout 218 - } 219 - }() 220 - 221 - // Wait for server to be ready 222 - if isQueryBased { 223 - // For localhost/query-based, just check if port is listening 224 - if !waitForPort(addr, 5*time.Second) { 225 - return nil, fmt.Errorf("server failed to start within 5 seconds") 226 - } 227 - } else { 228 - // For metadata URLs, check the metadata endpoint 229 - checkURL := "http://" + addr + metadataPath 230 - if !waitForServer(checkURL, 5*time.Second) { 231 - return nil, fmt.Errorf("server failed to start within 5 seconds") 232 - } 233 - } 234 - 235 - return server, nil 236 - } 237 - 238 - // waitForPort checks if a TCP port is listening 239 - func waitForPort(addr string, timeout time.Duration) bool { 240 - deadline := time.Now().Add(timeout) 241 - 242 - for time.Now().Before(deadline) { 243 - conn, err := net.DialTimeout("tcp", addr, 100*time.Millisecond) 244 - if err == nil { 245 - conn.Close() 246 - return true 247 - } 248 - time.Sleep(10 * time.Millisecond) 249 - } 250 - return false 251 - } 252 - 253 - // waitForServer checks if the server is responding at the given URL 254 - func waitForServer(url string, timeout time.Duration) bool { 255 - deadline := time.Now().Add(timeout) 256 - client := &http.Client{Timeout: 100 * time.Millisecond} 257 - 258 - for time.Now().Before(deadline) { 259 - resp, err := client.Get(url) 260 - if err == nil { 261 - resp.Body.Close() 262 - if resp.StatusCode == http.StatusOK { 263 - return true 264 - } 265 - } 266 - time.Sleep(10 * time.Millisecond) 267 - } 268 - return false 269 - }
+63 -208
pkg/auth/oauth/client.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "crypto/ecdsa" 6 - "crypto/elliptic" 7 - "crypto/rand" 8 - "crypto/sha256" 9 - "encoding/base64" 10 5 "fmt" 11 - "net/http" 12 - 13 6 "net/url" 14 7 "strings" 15 8 16 9 "atcr.io/pkg/atproto" 17 - "authelia.com/client/oauth2" 10 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 11 + "github.com/bluesky-social/indigo/atproto/syntax" 18 12 ) 19 13 20 - // Client is an OAuth client for ATProto with DPoP support 21 - type Client struct { 22 - config *oauth2.Config 23 - dpopKey *ecdsa.PrivateKey 24 - dpopTransport *DPoPTransport 25 - resolver *atproto.Resolver 26 - baseUrl string 27 - metadata *AuthServerMetadata 14 + // App wraps indigo's ClientApp with ATCR-specific configuration 15 + type App struct { 16 + clientApp *oauth.ClientApp 17 + baseURL string 18 + resolver *atproto.Resolver 28 19 } 29 20 30 - // NewClient creates a new OAuth client for ATProto from a base URL 31 - func NewClient(baseURL string) (*Client, error) { 32 - // Generate DPoP key 33 - dpopKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 34 - if err != nil { 35 - return nil, fmt.Errorf("failed to generate DPoP key: %w", err) 36 - } 21 + // NewApp creates a new OAuth app for ATCR 22 + func NewApp(baseURL string, store oauth.ClientAuthStore) (*App, error) { 23 + config := NewClientConfig(baseURL) 24 + clientApp := oauth.NewClientApp(&config, store) 37 25 38 - return NewClientWithKey(baseURL, dpopKey), nil 26 + return &App{ 27 + clientApp: clientApp, 28 + baseURL: baseURL, 29 + resolver: atproto.NewResolver(), 30 + }, nil 39 31 } 40 32 41 - // NewClientWithKey creates a new OAuth client with an existing DPoP key 42 - // This is useful when working with stored credentials (e.g., in AppView token refresh) 43 - func NewClientWithKey(baseURL string, dpopKey *ecdsa.PrivateKey) *Client { 44 - return &Client{ 45 - dpopKey: dpopKey, 46 - dpopTransport: NewDPoPTransport(http.DefaultTransport, dpopKey), 47 - resolver: atproto.NewResolver(), 48 - baseUrl: baseURL, 49 - } 50 - } 33 + // NewClientConfig creates an OAuth client configuration for ATCR 34 + func NewClientConfig(baseURL string) oauth.ClientConfig { 35 + clientID := ClientID(baseURL) 36 + redirectURI := RedirectURI(baseURL) 37 + scopes := GetDefaultScopes() 51 38 52 - // InitializeForHandle discovers the authorization server for a given handle/DID 53 - func (c *Client) InitializeForHandle(ctx context.Context, handle string) error { 54 - // Resolve handle to DID and PDS 55 - _, pdsEndpoint, err := c.resolver.ResolveIdentity(ctx, handle) 56 - if err != nil { 57 - return fmt.Errorf("failed to resolve identity: %w", err) 39 + // Check if this is localhost (public client) or production (confidential client) 40 + if strings.Contains(baseURL, "127.0.0.1") || strings.Contains(baseURL, "localhost") { 41 + return oauth.NewPublicConfig(clientID, redirectURI, scopes) 58 42 } 59 43 60 - return c.InitializeForPDS(ctx, pdsEndpoint) 44 + // Production: confidential client 45 + // Note: Client secrets would be configured separately if needed 46 + return oauth.NewPublicConfig(clientID, redirectURI, scopes) 61 47 } 62 48 63 - // InitializeForPDS discovers the authorization server for a given PDS endpoint 64 - // This is useful when you already know the PDS endpoint (e.g., from stored credentials) 65 - func (c *Client) InitializeForPDS(ctx context.Context, pdsEndpoint string) error { 66 - // Discover authorization server metadata 67 - metadata, err := DiscoverAuthServer(ctx, pdsEndpoint) 49 + // StartAuthFlow initiates an OAuth authorization flow for a given handle 50 + // Returns the authorization URL (state is stored in the auth store) 51 + func (a *App) StartAuthFlow(ctx context.Context, handle string) (authURL string, err error) { 52 + // Start auth flow with handle as identifier 53 + // Indigo will resolve the handle internally 54 + authURL, err = a.clientApp.StartAuthFlow(ctx, handle) 68 55 if err != nil { 69 - return fmt.Errorf("failed to discover authorization server: %w", err) 70 - } 71 - 72 - c.metadata = metadata 73 - 74 - // Configure OAuth2 client 75 - c.config = &oauth2.Config{ 76 - ClientID: c.ClientID(), 77 - Endpoint: oauth2.Endpoint{ 78 - AuthURL: metadata.AuthorizationEndpoint, 79 - TokenURL: metadata.TokenEndpoint, 80 - PushedAuthURL: metadata.PushedAuthorizationRequestEndpoint, 81 - }, 82 - RedirectURL: c.RedirectURI(), 83 - Scopes: c.GetDefaultScopes(), 56 + return "", fmt.Errorf("failed to start auth flow: %w", err) 84 57 } 85 58 86 - return nil 87 - } 88 - 89 - // SetScopes sets custom OAuth scopes (must be called after InitializeForHandle) 90 - func (c *Client) SetScopes(scopes []string) { 91 - if c.config != nil { 92 - c.config.Scopes = scopes 93 - } 59 + return authURL, nil 94 60 } 95 61 96 - // AuthorizeURL generates the authorization URL with PKCE 97 - func (c *Client) AuthorizeURL(state string) (authURL string, codeVerifier string, err error) { 98 - if c.config == nil { 99 - return "", "", fmt.Errorf("client not initialized - call InitializeForHandle first") 100 - } 101 - 102 - // Generate PKCE code verifier 103 - codeVerifier, err = generateCodeVerifier() 62 + // ProcessCallback processes an OAuth callback with authorization code and state 63 + // Returns ClientSessionData which contains the session information 64 + func (a *App) ProcessCallback(ctx context.Context, params url.Values) (*oauth.ClientSessionData, error) { 65 + sessionData, err := a.clientApp.ProcessCallback(ctx, params) 104 66 if err != nil { 105 - return "", "", fmt.Errorf("failed to generate code verifier: %w", err) 106 - } 107 - 108 - // Generate code challenge 109 - codeChallenge := generateCodeChallenge(codeVerifier) 110 - 111 - // Use PAR (Pushed Authorization Request) if supported 112 - if c.metadata.PushedAuthorizationRequestEndpoint != "" { 113 - authURL, err = c.authorizeURLWithPAR(state, codeChallenge) 114 - if err != nil { 115 - return "", "", fmt.Errorf("PAR failed: %w", err) 116 - } 117 - } else { 118 - // Fallback to standard authorization 119 - authURL = c.config.AuthCodeURL(state, 120 - oauth2.SetAuthURLParam("code_challenge", codeChallenge), 121 - oauth2.SetAuthURLParam("code_challenge_method", "S256"), 122 - ) 67 + return nil, fmt.Errorf("failed to process OAuth callback: %w", err) 123 68 } 124 69 125 - return authURL, codeVerifier, nil 70 + return sessionData, nil 126 71 } 127 72 128 - // authorizeURLWithPAR uses Pushed Authorization Request 129 - func (c *Client) authorizeURLWithPAR(state, codeChallenge string) (string, error) { 130 - fmt.Printf("DEBUG [oauth/client]: Starting PAR request\n") 131 - fmt.Printf("DEBUG [oauth/client]: - client_id: %s\n", c.config.ClientID) 132 - fmt.Printf("DEBUG [oauth/client]: - redirect_uri: %s\n", c.config.RedirectURL) 133 - fmt.Printf("DEBUG [oauth/client]: - scope: %v\n", c.config.Scopes) 134 - fmt.Printf("DEBUG [oauth/client]: - state: %s\n", state) 135 - fmt.Printf("DEBUG [oauth/client]: - code_challenge_method: S256\n") 136 - fmt.Printf("DEBUG [oauth/client]: - PAR endpoint: %s\n", c.config.Endpoint.PushedAuthURL) 137 - 138 - // Create HTTP client with DPoP transport 139 - ctx := context.WithValue(context.Background(), oauth2.HTTPClient, &http.Client{ 140 - Transport: c.dpopTransport, 141 - }) 142 - 143 - // Use authelia's PushedAuth method 144 - authURL, _, err := c.config.PushedAuth(ctx, state, 145 - oauth2.SetAuthURLParam("code_challenge", codeChallenge), 146 - oauth2.SetAuthURLParam("code_challenge_method", "S256"), 147 - ) 73 + // ResumeSession resumes an existing OAuth session 74 + // Returns a ClientSession that can be used to make authenticated requests 75 + func (a *App) ResumeSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSession, error) { 76 + session, err := a.clientApp.ResumeSession(ctx, did, sessionID) 148 77 if err != nil { 149 - fmt.Printf("ERROR [oauth/client]: PAR request failed: %v\n", err) 150 - return "", err 78 + return nil, fmt.Errorf("failed to resume session: %w", err) 151 79 } 152 80 153 - fmt.Printf("DEBUG [oauth/client]: PAR successful, authURL: %s\n", authURL.String()) 154 - return authURL.String(), nil 81 + return session, nil 155 82 } 156 83 157 - // Exchange exchanges an authorization code for an access token 158 - func (c *Client) Exchange(ctx context.Context, code, codeVerifier string) (*oauth2.Token, error) { 159 - if c.config == nil { 160 - return nil, fmt.Errorf("client not initialized") 161 - } 162 - 163 - // Create HTTP client with DPoP transport 164 - ctx = context.WithValue(ctx, oauth2.HTTPClient, &http.Client{ 165 - Transport: c.dpopTransport, 166 - }) 167 - 168 - // Exchange the code for a token 169 - token, err := c.config.Exchange(ctx, code, 170 - oauth2.SetAuthURLParam("code_verifier", codeVerifier), 171 - ) 172 - if err != nil { 173 - return nil, fmt.Errorf("failed to exchange code: %w", err) 174 - } 175 - 176 - return token, nil 84 + // GetClientApp returns the underlying indigo ClientApp 85 + // This is useful for advanced use cases that need direct access 86 + func (a *App) GetClientApp() *oauth.ClientApp { 87 + return a.clientApp 177 88 } 178 89 179 - // RefreshToken refreshes an access token using a refresh token 180 - func (c *Client) RefreshToken(ctx context.Context, refreshToken string) (*oauth2.Token, error) { 181 - if c.config == nil { 182 - return nil, fmt.Errorf("client not initialized") 183 - } 184 - 185 - // Create HTTP client with DPoP transport 186 - ctx = context.WithValue(ctx, oauth2.HTTPClient, &http.Client{ 187 - Transport: c.dpopTransport, 188 - }) 189 - 190 - // Refresh the token 191 - newToken, err := c.config.TokenSource(ctx, &oauth2.Token{ 192 - RefreshToken: refreshToken, 193 - }).Token() 194 - if err != nil { 195 - return nil, fmt.Errorf("failed to refresh token: %w", err) 196 - } 197 - 198 - // Set access token on transport for "ath" claim in future DPoP proofs 199 - c.dpopTransport.SetAccessToken(newToken.AccessToken) 200 - 201 - return newToken, nil 202 - } 203 - 204 - func (c *Client) ClientID() string { 205 - return c.ClientIDWithScopes(c.GetDefaultScopes()) 90 + // ClientID generates the OAuth client ID for ATCR 91 + func ClientID(baseURL string) string { 92 + return ClientIDWithScopes(baseURL, GetDefaultScopes()) 206 93 } 207 94 208 - func (c *Client) ClientIDWithScopes(scopes []string) string { 95 + // ClientIDWithScopes generates a client ID with custom scopes 96 + func ClientIDWithScopes(baseURL string, scopes []string) string { 209 97 scopeStr := strings.Join(scopes, " ") 210 - if strings.Contains(c.baseUrl, "127.0.0.1") || strings.Contains(c.baseUrl, "localhost") { 98 + if strings.Contains(baseURL, "127.0.0.1") || strings.Contains(baseURL, "localhost") { 211 99 // Localhost: use query-based client ID 212 100 return fmt.Sprintf("http://localhost?redirect_uri=%s&scope=%s", 213 - url.QueryEscape(c.RedirectURI()), 101 + url.QueryEscape(RedirectURI(baseURL)), 214 102 url.QueryEscape(scopeStr)) 215 103 } 216 104 // Production: use metadata URL 217 - return c.baseUrl + "/client-metadata.json" 105 + return baseURL + "/client-metadata.json" 218 106 } 219 107 220 - func (c *Client) RedirectURI() string { 221 - return c.baseUrl + "/auth/oauth/callback" 222 - } 223 - 224 - // DPoPKey returns the DPoP private key 225 - func (c *Client) DPoPKey() *ecdsa.PrivateKey { 226 - return c.dpopKey 227 - } 228 - 229 - // DPoPTransport returns the DPoP transport 230 - func (c *Client) DPoPTransport() *DPoPTransport { 231 - return c.dpopTransport 232 - } 233 - 234 - // SetDPoPKey sets the DPoP private key (useful when loading from storage) 235 - func (c *Client) SetDPoPKey(key *ecdsa.PrivateKey) { 236 - c.dpopKey = key 237 - c.dpopTransport = NewDPoPTransport(http.DefaultTransport, key) 108 + // RedirectURI returns the OAuth redirect URI for ATCR 109 + func RedirectURI(baseURL string) string { 110 + return baseURL + "/auth/oauth/callback" 238 111 } 239 112 240 113 // GetDefaultScopes returns the default OAuth scopes for ATCR registry operations 241 - func (c *Client) GetDefaultScopes() []string { 114 + func GetDefaultScopes() []string { 242 115 return []string{ 243 116 "atproto", 244 117 "transition:generic.full", ··· 249 122 fmt.Sprintf("repo:%s?action=update", atproto.TagCollection), 250 123 } 251 124 } 252 - 253 - // generateCodeVerifier generates a PKCE code verifier 254 - func generateCodeVerifier() (string, error) { 255 - // Generate 32 random bytes 256 - bytes := make([]byte, 32) 257 - if _, err := rand.Read(bytes); err != nil { 258 - return "", err 259 - } 260 - 261 - // Base64 URL encode 262 - return base64.RawURLEncoding.EncodeToString(bytes), nil 263 - } 264 - 265 - // generateCodeChallenge generates a PKCE code challenge from a verifier 266 - func generateCodeChallenge(verifier string) string { 267 - hash := sha256.Sum256([]byte(verifier)) 268 - return base64.RawURLEncoding.EncodeToString(hash[:]) 269 - }
-120
pkg/auth/oauth/discovery.go
··· 1 - package oauth 2 - 3 - import ( 4 - "context" 5 - "encoding/json" 6 - "fmt" 7 - "net/http" 8 - ) 9 - 10 - // ProtectedResourceMetadata represents the OAuth protected resource metadata 11 - // as defined in ATProto OAuth spec 12 - type ProtectedResourceMetadata struct { 13 - Resource string `json:"resource"` 14 - AuthorizationServers []string `json:"authorization_servers"` 15 - } 16 - 17 - // AuthServerMetadata represents the OAuth authorization server metadata 18 - // as defined in RFC 8414 19 - type AuthServerMetadata struct { 20 - Issuer string `json:"issuer"` 21 - AuthorizationEndpoint string `json:"authorization_endpoint"` 22 - TokenEndpoint string `json:"token_endpoint"` 23 - PushedAuthorizationRequestEndpoint string `json:"pushed_authorization_request_endpoint,omitempty"` 24 - RegistrationEndpoint string `json:"registration_endpoint,omitempty"` 25 - JWKsURI string `json:"jwks_uri,omitempty"` 26 - ScopesSupported []string `json:"scopes_supported,omitempty"` 27 - ResponseTypesSupported []string `json:"response_types_supported,omitempty"` 28 - GrantTypesSupported []string `json:"grant_types_supported,omitempty"` 29 - TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported,omitempty"` 30 - DPoPSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported,omitempty"` 31 - CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported,omitempty"` 32 - AuthorizationResponseIssParameterSupported bool `json:"authorization_response_iss_parameter_supported,omitempty"` 33 - } 34 - 35 - // DiscoverProtectedResource discovers the protected resource metadata 36 - // from the PDS endpoint to find the authorization servers 37 - func DiscoverProtectedResource(ctx context.Context, pdsEndpoint string) (*ProtectedResourceMetadata, error) { 38 - // Construct the well-known URL per ATProto OAuth spec 39 - discoveryURL := fmt.Sprintf("%s/.well-known/oauth-protected-resource", pdsEndpoint) 40 - 41 - req, err := http.NewRequestWithContext(ctx, "GET", discoveryURL, nil) 42 - if err != nil { 43 - return nil, fmt.Errorf("failed to create protected resource discovery request: %w", err) 44 - } 45 - 46 - client := &http.Client{} 47 - resp, err := client.Do(req) 48 - if err != nil { 49 - return nil, fmt.Errorf("failed to fetch protected resource metadata: %w", err) 50 - } 51 - defer resp.Body.Close() 52 - 53 - if resp.StatusCode != http.StatusOK { 54 - return nil, fmt.Errorf("protected resource discovery failed with status %d", resp.StatusCode) 55 - } 56 - 57 - var metadata ProtectedResourceMetadata 58 - if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil { 59 - return nil, fmt.Errorf("failed to decode protected resource metadata: %w", err) 60 - } 61 - 62 - // Validate required fields 63 - if len(metadata.AuthorizationServers) == 0 { 64 - return nil, fmt.Errorf("protected resource metadata missing authorization_servers") 65 - } 66 - 67 - return &metadata, nil 68 - } 69 - 70 - // DiscoverAuthServer discovers the OAuth authorization server metadata 71 - // using the ATProto two-step discovery process: 72 - // 1. Fetch protected resource metadata from PDS to get authorization server URL 73 - // 2. Fetch authorization server metadata from that URL 74 - func DiscoverAuthServer(ctx context.Context, pdsEndpoint string) (*AuthServerMetadata, error) { 75 - // Step 1: Discover the authorization server URL from the protected resource 76 - protectedResource, err := DiscoverProtectedResource(ctx, pdsEndpoint) 77 - if err != nil { 78 - return nil, fmt.Errorf("step 1 failed - discover protected resource from PDS %s: %w", pdsEndpoint, err) 79 - } 80 - 81 - // Use the first authorization server (ATProto spec allows multiple, but typically one) 82 - authServerURL := protectedResource.AuthorizationServers[0] 83 - 84 - // Step 2: Fetch authorization server metadata 85 - discoveryURL := fmt.Sprintf("%s/.well-known/oauth-authorization-server", authServerURL) 86 - 87 - req, err := http.NewRequestWithContext(ctx, "GET", discoveryURL, nil) 88 - if err != nil { 89 - return nil, fmt.Errorf("step 2 failed - create request: %w", err) 90 - } 91 - 92 - client := &http.Client{} 93 - resp, err := client.Do(req) 94 - if err != nil { 95 - return nil, fmt.Errorf("step 2 failed - fetch auth server metadata from %s: %w", discoveryURL, err) 96 - } 97 - defer resp.Body.Close() 98 - 99 - if resp.StatusCode != http.StatusOK { 100 - return nil, fmt.Errorf("step 2 failed - auth server discovery at %s returned status %d", discoveryURL, resp.StatusCode) 101 - } 102 - 103 - var metadata AuthServerMetadata 104 - if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil { 105 - return nil, fmt.Errorf("failed to decode authorization server metadata: %w", err) 106 - } 107 - 108 - // Validate required fields 109 - if metadata.Issuer == "" { 110 - return nil, fmt.Errorf("authorization server metadata missing issuer") 111 - } 112 - if metadata.AuthorizationEndpoint == "" { 113 - return nil, fmt.Errorf("authorization server metadata missing authorization_endpoint") 114 - } 115 - if metadata.TokenEndpoint == "" { 116 - return nil, fmt.Errorf("authorization server metadata missing token_endpoint") 117 - } 118 - 119 - return &metadata, nil 120 - }
-97
pkg/auth/oauth/flow.go
··· 1 - package oauth 2 - 3 - import ( 4 - "context" 5 - "fmt" 6 - "time" 7 - 8 - "authelia.com/client/oauth2" 9 - ) 10 - 11 - // InteractiveFlowConfig configures an interactive OAuth flow 12 - type InteractiveFlowConfig struct { 13 - BaseURL string // Base URL for OAuth callbacks (e.g., "http://127.0.0.1:8080") 14 - Handle string // ATProto handle or DID 15 - Scopes []string // Optional, defaults to GetDefaultScopes() 16 - } 17 - 18 - // FlowResult contains the result of a successful OAuth flow 19 - type FlowResult struct { 20 - Token *oauth2.Token 21 - Client *Client // OAuth client with DPoP key set 22 - } 23 - 24 - // RunInteractiveFlow executes an interactive OAuth authorization code flow 25 - // The setupCallback function is called TWICE: 26 - // 1. First with authURL="" to start the server (before PAR) 27 - // 2. Then with the actual authURL to display it to the user (after PAR) 28 - // 29 - // This two-phase approach ensures the server is running before PAR tries to fetch client metadata 30 - func RunInteractiveFlow(ctx context.Context, cfg InteractiveFlowConfig, 31 - setupCallback func(authURL string, handler *CallbackHandler, metadata *ClientMetadata) error) (*FlowResult, error) { 32 - 33 - // Create OAuth client from base URL 34 - client, err := NewClient(cfg.BaseURL) 35 - if err != nil { 36 - return nil, fmt.Errorf("failed to create OAuth client: %w", err) 37 - } 38 - 39 - // Initialize for the given handle 40 - initCtx, cancel := context.WithTimeout(ctx, 30*time.Second) 41 - defer cancel() 42 - 43 - if err := client.InitializeForHandle(initCtx, cfg.Handle); err != nil { 44 - return nil, fmt.Errorf("failed to initialize client: %w", err) 45 - } 46 - 47 - // Set scopes if provided 48 - if len(cfg.Scopes) > 0 { 49 - client.SetScopes(cfg.Scopes) 50 - } 51 - 52 - // Generate state for OAuth flow 53 - state, err := GenerateState() 54 - if err != nil { 55 - return nil, fmt.Errorf("failed to generate state: %w", err) 56 - } 57 - 58 - // Create callback handler and client metadata FIRST 59 - callbackHandler := NewCallbackHandler(state) 60 - metadata := NewClientMetadata(client.ClientID(), []string{client.RedirectURI()}) 61 - 62 - // Start server BEFORE generating auth URL (so PAR can fetch metadata) 63 - if err := setupCallback("", callbackHandler, metadata); err != nil { 64 - return nil, fmt.Errorf("callback setup failed: %w", err) 65 - } 66 - 67 - // NOW generate authorization URL with PKCE (PAR can succeed) 68 - authURL, codeVerifier, err := client.AuthorizeURL(state) 69 - if err != nil { 70 - return nil, fmt.Errorf("failed to generate auth URL: %w", err) 71 - } 72 - 73 - // Display the auth URL (callback gets called again with URL) 74 - if err := setupCallback(authURL, callbackHandler, metadata); err != nil { 75 - return nil, fmt.Errorf("failed to display auth URL: %w", err) 76 - } 77 - 78 - // Wait for callback (5 minute timeout) 79 - code, err := callbackHandler.WaitForCode(5 * time.Minute) 80 - if err != nil { 81 - return nil, err 82 - } 83 - 84 - // Exchange code for token 85 - exchangeCtx, cancel := context.WithTimeout(ctx, 30*time.Second) 86 - defer cancel() 87 - 88 - token, err := client.Exchange(exchangeCtx, code, codeVerifier) 89 - if err != nil { 90 - return nil, fmt.Errorf("failed to exchange code: %w", err) 91 - } 92 - 93 - return &FlowResult{ 94 - Token: token, 95 - Client: client, 96 - }, nil 97 - }
-50
pkg/auth/oauth/metadata.go
··· 1 - package oauth 2 - 3 - import ( 4 - "encoding/json" 5 - "net/http" 6 - ) 7 - 8 - // ClientMetadata represents the OAuth client metadata document 9 - // This follows the ATProto OAuth client metadata specification 10 - type ClientMetadata struct { 11 - ClientID string `json:"client_id"` 12 - ClientName string `json:"client_name,omitempty"` 13 - ClientURI string `json:"client_uri,omitempty"` 14 - RedirectURIs []string `json:"redirect_uris"` 15 - GrantTypes []string `json:"grant_types"` 16 - ResponseTypes []string `json:"response_types"` 17 - Scope string `json:"scope"` 18 - TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` 19 - ApplicationType string `json:"application_type"` 20 - DPoPBoundAccessTokens bool `json:"dpop_bound_access_tokens"` 21 - } 22 - 23 - // NewClientMetadata creates a client metadata document for ATProto OAuth 24 - func NewClientMetadata(clientID string, redirectURIs []string) *ClientMetadata { 25 - return &ClientMetadata{ 26 - ClientID: clientID, 27 - ClientName: "ATCR Registry", 28 - ClientURI: "https://github.com/yourusername/atcr.io", 29 - RedirectURIs: redirectURIs, 30 - GrantTypes: []string{"authorization_code", "refresh_token"}, 31 - ResponseTypes: []string{"code"}, 32 - Scope: "atproto", 33 - TokenEndpointAuthMethod: "none", // Public client 34 - ApplicationType: "native", 35 - DPoPBoundAccessTokens: true, 36 - } 37 - } 38 - 39 - // ServeMetadata returns an HTTP handler that serves the client metadata JSON 40 - func ServeMetadata(metadata *ClientMetadata) http.HandlerFunc { 41 - return func(w http.ResponseWriter, r *http.Request) { 42 - w.Header().Set("Content-Type", "application/json") 43 - w.Header().Set("Access-Control-Allow-Origin", "*") 44 - 45 - if err := json.NewEncoder(w).Encode(metadata); err != nil { 46 - http.Error(w, "failed to encode metadata", http.StatusInternalServerError) 47 - return 48 - } 49 - } 50 - }
+140 -106
pkg/auth/oauth/refresher.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "crypto/ecdsa" 6 5 "fmt" 6 + "net/http" 7 7 "sync" 8 - "time" 8 + 9 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 9 11 ) 10 12 11 - // AccessTokenEntry represents a cached access token 12 - type AccessTokenEntry struct { 13 - Token string 14 - DPoPKey *ecdsa.PrivateKey 15 - PDS string // Store PDS endpoint to create fresh transports 16 - ExpiresAt time.Time 13 + // SessionCache represents a cached OAuth session 14 + type SessionCache struct { 15 + Session *oauth.ClientSession 16 + SessionID string 17 17 } 18 18 19 - // Refresher manages OAuth token refresh for AppView 19 + // Refresher manages OAuth sessions and token refresh for AppView 20 20 type Refresher struct { 21 - storage *RefreshTokenStorage 22 - accessTokens map[string]*AccessTokenEntry 21 + app *App 22 + sessions map[string]*SessionCache // Key: DID string 23 23 mu sync.RWMutex 24 24 refreshLocks map[string]*sync.Mutex // Per-DID locks for refresh operations 25 25 refreshLockMu sync.Mutex // Protects refreshLocks map 26 - baseURL string 27 26 } 28 27 29 - // NewRefresher creates a new token refresher 30 - func NewRefresher(storage *RefreshTokenStorage, baseURL string) *Refresher { 28 + // NewRefresher creates a new session refresher 29 + func NewRefresher(app *App) *Refresher { 31 30 return &Refresher{ 32 - storage: storage, 33 - accessTokens: make(map[string]*AccessTokenEntry), 31 + app: app, 32 + sessions: make(map[string]*SessionCache), 34 33 refreshLocks: make(map[string]*sync.Mutex), 35 - baseURL: baseURL, 36 34 } 37 35 } 38 36 39 - // GetAccessToken gets a fresh access token for a DID 40 - // Returns cached token if still valid, otherwise refreshes 41 - // Returns: accessToken, dpopKey, dpopTransport, error 42 - func (r *Refresher) GetAccessToken(ctx context.Context, did string) (string, *ecdsa.PrivateKey, *DPoPTransport, error) { 37 + // GetSession gets a fresh OAuth session for a DID 38 + // Returns cached session if still valid, otherwise resumes from store 39 + func (r *Refresher) GetSession(ctx context.Context, did string) (*oauth.ClientSession, error) { 43 40 // Check cache first (fast path) 44 41 r.mu.RLock() 45 - entry, ok := r.accessTokens[did] 42 + cached, ok := r.sessions[did] 46 43 r.mu.RUnlock() 47 44 48 - if ok && time.Now().Before(entry.ExpiresAt) { 49 - // Token still valid - create fresh transport to avoid nonce reuse 50 - transport := NewDPoPTransport(nil, entry.DPoPKey) 51 - transport.SetAccessToken(entry.Token) 52 - return entry.Token, entry.DPoPKey, transport, nil 45 + if ok && cached.Session != nil { 46 + // Session cached, tokens will auto-refresh if needed 47 + return cached.Session, nil 53 48 } 54 49 55 - // Token expired or not cached, need to refresh 56 - // Get or create per-DID lock to prevent concurrent refreshes 50 + // Session not cached, need to resume from store 51 + // Get or create per-DID lock to prevent concurrent resume operations 57 52 r.refreshLockMu.Lock() 58 53 didLock, ok := r.refreshLocks[did] 59 54 if !ok { ··· 66 61 didLock.Lock() 67 62 defer didLock.Unlock() 68 63 69 - // Double-check cache after acquiring lock (another goroutine might have refreshed) 64 + // Double-check cache after acquiring lock (another goroutine might have loaded it) 70 65 r.mu.RLock() 71 - entry, ok = r.accessTokens[did] 66 + cached, ok = r.sessions[did] 72 67 r.mu.RUnlock() 73 68 74 - if ok && time.Now().Before(entry.ExpiresAt) { 75 - // Token was refreshed while we waited for the lock - create fresh transport 76 - transport := NewDPoPTransport(nil, entry.DPoPKey) 77 - transport.SetAccessToken(entry.Token) 78 - return entry.Token, entry.DPoPKey, transport, nil 69 + if ok && cached.Session != nil { 70 + return cached.Session, nil 79 71 } 80 72 81 - // Actually refresh the token 82 - return r.RefreshToken(ctx, did) 73 + // Actually resume the session 74 + return r.resumeSession(ctx, did) 83 75 } 84 76 85 - // RefreshToken forces a token refresh for a DID 86 - // Returns: accessToken, dpopKey, dpopTransport, error 87 - func (r *Refresher) RefreshToken(ctx context.Context, did string) (string, *ecdsa.PrivateKey, *DPoPTransport, error) { 88 - // Get stored refresh token 89 - entry, err := r.storage.Get(did) 77 + // GetAccessToken gets a fresh access token for a DID 78 + // This is a convenience method that extracts the access token from the session 79 + func (r *Refresher) GetAccessToken(ctx context.Context, did string) (string, error) { 80 + session, err := r.GetSession(ctx, did) 90 81 if err != nil { 91 - return "", nil, nil, fmt.Errorf("failed to get stored refresh token: %w", err) 82 + return "", err 92 83 } 93 84 94 - // Parse DPoP key 95 - dpopKey, err := r.storage.GetDPoPKey(did) 85 + // Get access token and DPoP nonce from session 86 + accessToken, _ := session.GetHostAccessData() 87 + return accessToken, nil 88 + } 89 + 90 + // GetHTTPClient returns an HTTP client with DPoP authentication for a DID 91 + // The client automatically adds DPoP headers and refreshes tokens as needed 92 + func (r *Refresher) GetHTTPClient(ctx context.Context, did string) (*http.Client, error) { 93 + session, err := r.GetSession(ctx, did) 96 94 if err != nil { 97 - return "", nil, nil, fmt.Errorf("failed to get DPoP key: %w", err) 95 + return nil, err 98 96 } 99 97 100 - // Create OAuth client with stored DPoP key 101 - client := NewClientWithKey(r.baseURL, dpopKey) 98 + // Get API client from session 99 + // This client automatically handles DPoP and token refresh 100 + apiClient := session.APIClient() 101 + return apiClient.Client, nil 102 + } 102 103 103 - // Initialize for PDS endpoint 104 - if err := client.InitializeForPDS(ctx, entry.PDS); err != nil { 105 - return "", nil, nil, fmt.Errorf("failed to initialize OAuth client: %w", err) 106 - } 107 - 108 - // Refresh the token 109 - token, err := client.RefreshToken(ctx, entry.RefreshToken) 104 + // resumeSession loads a session from storage and caches it 105 + func (r *Refresher) resumeSession(ctx context.Context, did string) (*oauth.ClientSession, error) { 106 + // Parse DID 107 + accountDID, err := syntax.ParseDID(did) 110 108 if err != nil { 111 - return "", nil, nil, fmt.Errorf("failed to refresh token: %w", err) 109 + return nil, fmt.Errorf("failed to parse DID: %w", err) 112 110 } 113 111 114 - // Update last refresh timestamp 115 - if err := r.storage.UpdateLastRefresh(did); err != nil { 116 - // Log but don't fail - this is not critical 117 - fmt.Printf("WARNING: failed to update last refresh timestamp for %s: %v\n", did, err) 112 + // Get all sessions for this DID from store 113 + fileStore, ok := r.app.clientApp.Store.(*FileStore) 114 + if !ok { 115 + return nil, fmt.Errorf("store is not a FileStore") 118 116 } 119 117 120 - // If a new refresh token was issued, update storage 121 - if token.RefreshToken != "" && token.RefreshToken != entry.RefreshToken { 122 - entry.RefreshToken = token.RefreshToken 123 - if err := r.storage.Store(did, entry); err != nil { 124 - // Log but don't fail - we have the access token 125 - fmt.Printf("WARNING: failed to update refresh token for %s: %v\n", did, err) 118 + // Find a session for this DID 119 + sessions := fileStore.ListSessions() 120 + var sessionID string 121 + for _, sessionData := range sessions { 122 + if sessionData.AccountDID.String() == did { 123 + sessionID = sessionData.SessionID 124 + break 126 125 } 127 126 } 128 127 129 - // Cache the access token (but not transport - create fresh each time) 130 - // Expire 1 minute early to avoid edge cases 131 - expiresAt := token.Expiry.Add(-1 * time.Minute) 128 + if sessionID == "" { 129 + return nil, fmt.Errorf("no session found for DID: %s", did) 130 + } 131 + 132 + // Resume session 133 + session, err := r.app.ResumeSession(ctx, accountDID, sessionID) 134 + if err != nil { 135 + return nil, fmt.Errorf("failed to resume session: %w", err) 136 + } 132 137 138 + // Cache the session 133 139 r.mu.Lock() 134 - r.accessTokens[did] = &AccessTokenEntry{ 135 - Token: token.AccessToken, 136 - DPoPKey: dpopKey, 137 - PDS: entry.PDS, 138 - ExpiresAt: expiresAt, 140 + r.sessions[did] = &SessionCache{ 141 + Session: session, 142 + SessionID: sessionID, 139 143 } 140 144 r.mu.Unlock() 141 145 142 - // Create fresh transport for this request 143 - dpopTransport := NewDPoPTransport(nil, dpopKey) 144 - dpopTransport.SetAccessToken(token.AccessToken) 145 - 146 - return token.AccessToken, dpopKey, dpopTransport, nil 146 + return session, nil 147 147 } 148 148 149 - // InvalidateAccessToken removes a cached access token for a DID 150 - // This is useful when a new refresh token is obtained (e.g., after re-authorization) 151 - func (r *Refresher) InvalidateAccessToken(did string) { 149 + // InvalidateSession removes a cached session for a DID 150 + // This is useful when a new OAuth flow creates a fresh session 151 + func (r *Refresher) InvalidateSession(did string) { 152 152 r.mu.Lock() 153 - delete(r.accessTokens, did) 153 + delete(r.sessions, did) 154 154 r.mu.Unlock() 155 155 } 156 156 157 - // RevokeToken removes stored refresh token and cached access token 158 - func (r *Refresher) RevokeToken(did string) error { 157 + // RevokeSession removes a session from both cache and storage 158 + func (r *Refresher) RevokeSession(ctx context.Context, did string) error { 159 + // Remove from cache 159 160 r.mu.Lock() 160 - delete(r.accessTokens, did) 161 + cached, ok := r.sessions[did] 162 + delete(r.sessions, did) 161 163 r.mu.Unlock() 162 164 163 - return r.storage.Delete(did) 165 + if !ok { 166 + // Not cached, still try to delete from storage 167 + accountDID, err := syntax.ParseDID(did) 168 + if err != nil { 169 + return fmt.Errorf("failed to parse DID: %w", err) 170 + } 171 + 172 + // Find session ID from store 173 + fileStore, ok := r.app.clientApp.Store.(*FileStore) 174 + if !ok { 175 + return fmt.Errorf("store is not a FileStore") 176 + } 177 + 178 + sessions := fileStore.ListSessions() 179 + for _, sessionData := range sessions { 180 + if sessionData.AccountDID.String() == did { 181 + return r.app.clientApp.Store.DeleteSession(ctx, accountDID, sessionData.SessionID) 182 + } 183 + } 184 + 185 + return fmt.Errorf("no session found for DID: %s", did) 186 + } 187 + 188 + // Revoke the session via OAuth 189 + if err := cached.Session.RevokeSession(ctx); err != nil { 190 + fmt.Printf("WARNING: failed to revoke session for %s: %v\n", did, err) 191 + // Continue anyway to delete from storage 192 + } 193 + 194 + // Delete from storage 195 + accountDID, err := syntax.ParseDID(did) 196 + if err != nil { 197 + return fmt.Errorf("failed to parse DID: %w", err) 198 + } 199 + 200 + return r.app.clientApp.Store.DeleteSession(ctx, accountDID, cached.SessionID) 164 201 } 165 202 166 - // CleanupExpiredTokens removes expired access tokens from cache 167 - // Should be called periodically (e.g., every hour) 168 - func (r *Refresher) CleanupExpiredTokens() { 203 + // CleanupExpiredSessions removes expired sessions from cache 204 + // Note: indigo handles token expiry automatically, but we clean up orphaned cache entries 205 + func (r *Refresher) CleanupExpiredSessions(ctx context.Context) { 169 206 r.mu.Lock() 170 207 defer r.mu.Unlock() 171 208 172 - now := time.Now() 173 - for did, entry := range r.accessTokens { 174 - if now.After(entry.ExpiresAt) { 175 - delete(r.accessTokens, did) 209 + // For each cached session, verify it still exists in storage 210 + for did, cached := range r.sessions { 211 + accountDID, err := syntax.ParseDID(did) 212 + if err != nil { 213 + delete(r.sessions, did) 214 + continue 176 215 } 177 - } 178 - } 179 216 180 - // StartCleanupRoutine starts a background goroutine to cleanup expired tokens 181 - func (r *Refresher) StartCleanupRoutine(interval time.Duration) { 182 - go func() { 183 - ticker := time.NewTicker(interval) 184 - defer ticker.Stop() 185 - 186 - for range ticker.C { 187 - r.CleanupExpiredTokens() 217 + // Try to get session from store 218 + _, err = r.app.clientApp.Store.GetSession(ctx, accountDID, cached.SessionID) 219 + if err != nil { 220 + // Session no longer exists, remove from cache 221 + delete(r.sessions, did) 188 222 } 189 - }() 223 + } 190 224 }
+55 -159
pkg/auth/oauth/server.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "crypto/ecdsa" 6 - "crypto/rand" 7 5 "fmt" 8 6 "html/template" 9 7 "net/http" 10 - "sync" 11 8 "time" 12 9 13 - "atcr.io/pkg/atproto" 14 10 "atcr.io/pkg/auth/session" 15 11 ) 16 12 ··· 21 17 22 18 // Server handles OAuth authorization for the AppView 23 19 type Server struct { 24 - storage *RefreshTokenStorage 20 + app *App 25 21 sessionManager *session.Manager 26 - resolver *atproto.Resolver 27 22 refresher *Refresher 28 23 uiSessionStore UISessionStore 29 - baseURL string 30 - states map[string]*OAuthState 31 - statesMu sync.RWMutex 32 - } 33 - 34 - // OAuthState tracks an in-progress OAuth flow 35 - type OAuthState struct { 36 - State string 37 - Handle string 38 - DID string 39 - PDSEndpoint string 40 - CodeVerifier string 41 - DPoPKey *ecdsa.PrivateKey 42 - CreatedAt time.Time 43 24 } 44 25 45 26 // NewServer creates a new OAuth server 46 - func NewServer(storage *RefreshTokenStorage, sessionManager *session.Manager, baseURL string) *Server { 27 + func NewServer(app *App, sessionManager *session.Manager) *Server { 47 28 return &Server{ 48 - storage: storage, 29 + app: app, 49 30 sessionManager: sessionManager, 50 - resolver: atproto.NewResolver(), 51 - refresher: nil, // Will be set via SetRefresher() 52 - baseURL: baseURL, 53 - states: make(map[string]*OAuthState), 54 31 } 55 32 } 56 33 57 - // SetRefresher sets the refresher for invalidating access token cache 34 + // SetRefresher sets the refresher for invalidating session cache 58 35 func (s *Server) SetRefresher(refresher *Refresher) { 59 36 s.refresher = refresher 60 37 } ··· 80 57 81 58 fmt.Printf("DEBUG [oauth/server]: Starting OAuth flow for handle=%s\n", handle) 82 59 83 - // Resolve handle to DID and PDS 84 - did, pdsEndpoint, err := s.resolver.ResolveIdentity(r.Context(), handle) 60 + // Start auth flow via indigo 61 + authURL, err := s.app.StartAuthFlow(r.Context(), handle) 85 62 if err != nil { 86 - fmt.Printf("ERROR [oauth/server]: Failed to resolve handle: %v\n", err) 87 - http.Error(w, fmt.Sprintf("failed to resolve handle: %v", err), http.StatusBadRequest) 88 - return 89 - } 90 - 91 - fmt.Printf("DEBUG [oauth/server]: Resolved handle=%s -> did=%s, pds=%s\n", handle, did, pdsEndpoint) 92 - 93 - // Create OAuth client from base URL 94 - fmt.Printf("DEBUG [oauth/server]: Creating OAuth client for baseURL=%s\n", s.baseURL) 95 - client, err := NewClient(s.baseURL) 96 - if err != nil { 97 - fmt.Printf("ERROR [oauth/server]: Failed to create OAuth client: %v\n", err) 98 - http.Error(w, fmt.Sprintf("failed to create OAuth client: %v", err), http.StatusInternalServerError) 99 - return 100 - } 101 - 102 - // Initialize for the handle's PDS 103 - fmt.Printf("DEBUG [oauth/server]: Initializing OAuth client for handle=%s\n", handle) 104 - if err := client.InitializeForHandle(r.Context(), handle); err != nil { 105 - fmt.Printf("ERROR [oauth/server]: Failed to initialize OAuth: %v\n", err) 106 - http.Error(w, fmt.Sprintf("failed to initialize OAuth: %v", err), http.StatusInternalServerError) 107 - return 108 - } 109 - 110 - // Generate authorization URL 111 - state := generateState() 112 - fmt.Printf("DEBUG [oauth/server]: Generating authorization URL with state=%s\n", state) 113 - authURL, codeVerifier, err := client.AuthorizeURL(state) 114 - if err != nil { 115 - fmt.Printf("ERROR [oauth/server]: Failed to generate auth URL: %v\n", err) 116 - http.Error(w, fmt.Sprintf("failed to generate auth URL: %v", err), http.StatusInternalServerError) 63 + fmt.Printf("ERROR [oauth/server]: Failed to start auth flow: %v\n", err) 64 + http.Error(w, fmt.Sprintf("failed to start auth flow: %v", err), http.StatusInternalServerError) 117 65 return 118 66 } 119 67 120 68 fmt.Printf("DEBUG [oauth/server]: Generated authURL=%s\n", authURL) 121 - 122 - // Store state for callback 123 - s.statesMu.Lock() 124 - s.states[state] = &OAuthState{ 125 - State: state, 126 - Handle: handle, 127 - DID: did, 128 - PDSEndpoint: pdsEndpoint, 129 - CodeVerifier: codeVerifier, 130 - DPoPKey: client.dpopKey, 131 - CreatedAt: time.Now(), 132 - } 133 - s.statesMu.Unlock() 134 69 135 70 // Redirect to PDS authorization page 71 + // Note: indigo handles state internally via the auth store 136 72 http.Redirect(w, r, authURL, http.StatusFound) 137 73 } 138 74 ··· 143 79 return 144 80 } 145 81 146 - // Get code and state from query parameters 147 - code := r.URL.Query().Get("code") 148 - state := r.URL.Query().Get("state") 82 + // Check for OAuth error 83 + if errorParam := r.URL.Query().Get("error"); errorParam != "" { 84 + errorDesc := r.URL.Query().Get("error_description") 85 + s.renderError(w, fmt.Sprintf("OAuth error: %s - %s", errorParam, errorDesc)) 86 + return 87 + } 149 88 150 - if code == "" || state == "" { 151 - s.renderError(w, "Missing code or state parameter") 89 + // Process OAuth callback via indigo (handles state validation internally) 90 + sessionData, err := s.app.ProcessCallback(r.Context(), r.URL.Query()) 91 + if err != nil { 92 + s.renderError(w, fmt.Sprintf("Failed to process OAuth callback: %v", err)) 152 93 return 153 94 } 154 95 155 - // Retrieve OAuth state 156 - s.statesMu.Lock() 157 - oauthState, ok := s.states[state] 158 - delete(s.states, state) // Consume state 159 - s.statesMu.Unlock() 96 + did := sessionData.AccountDID.String() 97 + sessionID := sessionData.SessionID 160 98 161 - if !ok { 162 - s.renderError(w, "Invalid or expired state") 163 - return 99 + fmt.Printf("DEBUG [oauth/server]: OAuth callback successful for DID=%s, sessionID=%s\n", did, sessionID) 100 + 101 + // Invalidate cached session (if any) since we have a new session with new tokens 102 + if s.refresher != nil { 103 + s.refresher.InvalidateSession(did) 104 + fmt.Printf("DEBUG [oauth/server]: Invalidated cached session for DID=%s after creating new session\n", did) 105 + } 106 + 107 + // We need to get the handle for the session token 108 + // Resolve DID to handle using our resolver 109 + handle, err := s.resolveHandle(r.Context(), did) 110 + if err != nil { 111 + fmt.Printf("WARNING [oauth/server]: Failed to resolve DID to handle: %v, using DID as handle\n", err) 112 + handle = did // Fallback to DID if resolution fails 164 113 } 165 114 166 - // Exchange code for tokens 167 - sessionToken, err := s.exchangeCodeForSession(r.Context(), code, oauthState) 115 + // Create session token for credential helper 116 + sessionToken, err := s.sessionManager.Create(did, handle) 168 117 if err != nil { 169 - s.renderError(w, fmt.Sprintf("Failed to exchange code: %v", err)) 118 + s.renderError(w, fmt.Sprintf("Failed to create session token: %v", err)) 170 119 return 171 120 } 172 121 173 122 // Check if this is a UI login (has oauth_return_to cookie) 174 123 if cookie, err := r.Cookie("oauth_return_to"); err == nil && s.uiSessionStore != nil { 175 - // Create UI session with PDS endpoint 176 - sessionID, err := s.uiSessionStore.Create(oauthState.DID, oauthState.Handle, oauthState.PDSEndpoint, 24*time.Hour) 124 + // Create UI session 125 + uiSessionID, err := s.uiSessionStore.Create(did, handle, sessionData.HostURL, 24*time.Hour) 177 126 if err != nil { 178 127 s.renderError(w, fmt.Sprintf("Failed to create UI session: %v", err)) 179 128 return ··· 182 131 // Set UI session cookie 183 132 http.SetCookie(w, &http.Cookie{ 184 133 Name: "atcr_session", 185 - Value: sessionID, 134 + Value: uiSessionID, 186 135 Path: "/", 187 136 MaxAge: 86400, // 24 hours 188 137 HttpOnly: true, ··· 209 158 } 210 159 211 160 // Render success page with session token (for credential helper) 212 - s.renderSuccess(w, sessionToken, oauthState.Handle) 161 + s.renderSuccess(w, sessionToken, handle) 213 162 } 214 163 215 - // exchangeCodeForSession exchanges authorization code for tokens and creates session 216 - func (s *Server) exchangeCodeForSession(ctx context.Context, code string, state *OAuthState) (string, error) { 217 - // Create OAuth client with stored DPoP key 218 - client := NewClientWithKey(s.baseURL, state.DPoPKey) 219 - 220 - // Initialize for PDS endpoint 221 - if err := client.InitializeForPDS(ctx, state.PDSEndpoint); err != nil { 222 - return "", fmt.Errorf("failed to initialize OAuth client: %w", err) 223 - } 224 - 225 - // Exchange code for token 226 - token, err := client.Exchange(ctx, code, state.CodeVerifier) 164 + // resolveHandle attempts to resolve a DID to a handle 165 + // This is a best-effort helper - we use the resolver to look up the handle 166 + func (s *Server) resolveHandle(ctx context.Context, did string) (string, error) { 167 + // Parse the DID document to get the handle 168 + // Note: This is a simple implementation - in production we might want to cache this 169 + doc, err := s.app.resolver.ResolveDIDDocument(ctx, did) 227 170 if err != nil { 228 - return "", fmt.Errorf("failed to exchange code: %w", err) 171 + return "", fmt.Errorf("failed to resolve DID document: %w", err) 229 172 } 230 173 231 - // Encode DPoP key to PEM 232 - dpopKeyPEM, err := EncodeDPoPKey(state.DPoPKey) 233 - if err != nil { 234 - return "", fmt.Errorf("failed to encode DPoP key: %w", err) 174 + // Try to find a handle in the alsoKnownAs field 175 + for _, aka := range doc.AlsoKnownAs { 176 + if len(aka) > 5 && aka[:5] == "at://" { 177 + return aka[5:], nil 178 + } 235 179 } 236 180 237 - // Store refresh token 238 - refreshEntry := &RefreshTokenEntry{ 239 - RefreshToken: token.RefreshToken, 240 - DPoPKeyPEM: dpopKeyPEM, 241 - PDS: state.PDSEndpoint, 242 - Handle: state.Handle, 243 - CreatedAt: time.Now(), 244 - LastRefresh: time.Now(), 245 - } 246 - 247 - if err := s.storage.Store(state.DID, refreshEntry); err != nil { 248 - return "", fmt.Errorf("failed to store refresh token: %w", err) 249 - } 250 - 251 - // Invalidate cached access token (if any) since we have a new refresh token with new scopes 252 - if s.refresher != nil { 253 - s.refresher.InvalidateAccessToken(state.DID) 254 - fmt.Printf("DEBUG [oauth/server]: Invalidated cached access token for DID=%s after storing new refresh token\n", state.DID) 255 - } 256 - 257 - // Create session token for credential helper 258 - sessionToken, err := s.sessionManager.Create(state.DID, state.Handle) 259 - if err != nil { 260 - return "", fmt.Errorf("failed to create session token: %w", err) 261 - } 262 - 263 - return sessionToken, nil 181 + return "", fmt.Errorf("no handle found in DID document") 264 182 } 265 183 266 184 // renderSuccess renders the success page ··· 294 212 if err := tmpl.Execute(w, data); err != nil { 295 213 http.Error(w, "failed to render template", http.StatusInternalServerError) 296 214 } 297 - } 298 - 299 - // CleanupExpiredStates removes expired OAuth states 300 - // Should be called periodically 301 - func (s *Server) CleanupExpiredStates() { 302 - s.statesMu.Lock() 303 - defer s.statesMu.Unlock() 304 - 305 - now := time.Now() 306 - for state, oauthState := range s.states { 307 - // States expire after 10 minutes 308 - if now.Sub(oauthState.CreatedAt) > 10*time.Minute { 309 - delete(s.states, state) 310 - } 311 - } 312 - } 313 - 314 - // generateState generates a random state parameter 315 - func generateState() string { 316 - b := make([]byte, 32) 317 - rand.Read(b) 318 - return fmt.Sprintf("%x", b) 319 215 } 320 216 321 217 // HTML templates
-112
pkg/auth/oauth/storage.go
··· 1 - package oauth 2 - 3 - import ( 4 - "crypto/ecdsa" 5 - "crypto/x509" 6 - "encoding/json" 7 - "encoding/pem" 8 - "fmt" 9 - "os" 10 - "path/filepath" 11 - "time" 12 - ) 13 - 14 - // TokenStore represents persisted OAuth tokens and DPoP key 15 - type TokenStore struct { 16 - AccessToken string `json:"access_token"` 17 - RefreshToken string `json:"refresh_token,omitempty"` 18 - TokenType string `json:"token_type"` 19 - ExpiresAt time.Time `json:"expires_at"` 20 - DPoPKeyPEM string `json:"dpop_key_pem"` // ECDSA private key in PEM format 21 - DID string `json:"did,omitempty"` 22 - Handle string `json:"handle,omitempty"` 23 - } 24 - 25 - // Save persists the token store to a file 26 - func (s *TokenStore) Save(path string) error { 27 - // Ensure directory exists 28 - dir := filepath.Dir(path) 29 - if err := os.MkdirAll(dir, 0700); err != nil { 30 - return fmt.Errorf("failed to create token directory: %w", err) 31 - } 32 - 33 - // Marshal to JSON 34 - data, err := json.MarshalIndent(s, "", " ") 35 - if err != nil { 36 - return fmt.Errorf("failed to marshal token store: %w", err) 37 - } 38 - 39 - // Write to file with secure permissions 40 - if err := os.WriteFile(path, data, 0600); err != nil { 41 - return fmt.Errorf("failed to write token store: %w", err) 42 - } 43 - 44 - return nil 45 - } 46 - 47 - // LoadTokenStore loads a token store from a file 48 - func LoadTokenStore(path string) (*TokenStore, error) { 49 - data, err := os.ReadFile(path) 50 - if err != nil { 51 - return nil, fmt.Errorf("failed to read token store: %w", err) 52 - } 53 - 54 - var store TokenStore 55 - if err := json.Unmarshal(data, &store); err != nil { 56 - return nil, fmt.Errorf("failed to unmarshal token store: %w", err) 57 - } 58 - 59 - return &store, nil 60 - } 61 - 62 - // GetDPoPKey decodes the PEM-encoded DPoP private key 63 - func (s *TokenStore) GetDPoPKey() (*ecdsa.PrivateKey, error) { 64 - block, _ := pem.Decode([]byte(s.DPoPKeyPEM)) 65 - if block == nil { 66 - return nil, fmt.Errorf("failed to decode PEM block") 67 - } 68 - 69 - key, err := x509.ParseECPrivateKey(block.Bytes) 70 - if err != nil { 71 - return nil, fmt.Errorf("failed to parse EC private key: %w", err) 72 - } 73 - 74 - return key, nil 75 - } 76 - 77 - // SetDPoPKey encodes the DPoP private key as PEM 78 - func (s *TokenStore) SetDPoPKey(key *ecdsa.PrivateKey) error { 79 - keyBytes, err := x509.MarshalECPrivateKey(key) 80 - if err != nil { 81 - return fmt.Errorf("failed to marshal EC private key: %w", err) 82 - } 83 - 84 - pemBlock := &pem.Block{ 85 - Type: "EC PRIVATE KEY", 86 - Bytes: keyBytes, 87 - } 88 - 89 - s.DPoPKeyPEM = string(pem.EncodeToMemory(pemBlock)) 90 - return nil 91 - } 92 - 93 - // IsExpired checks if the access token is expired 94 - func (s *TokenStore) IsExpired() bool { 95 - // Add a 60 second buffer to refresh before actual expiry 96 - return time.Now().After(s.ExpiresAt.Add(-60 * time.Second)) 97 - } 98 - 99 - // ParseDPoPKey parses a PEM-encoded ECDSA private key 100 - func ParseDPoPKey(pemData string) (*ecdsa.PrivateKey, error) { 101 - block, _ := pem.Decode([]byte(pemData)) 102 - if block == nil { 103 - return nil, fmt.Errorf("failed to decode PEM block") 104 - } 105 - 106 - key, err := x509.ParseECPrivateKey(block.Bytes) 107 - if err != nil { 108 - return nil, fmt.Errorf("failed to parse EC private key: %w", err) 109 - } 110 - 111 - return key, nil 112 - }
-200
pkg/auth/oauth/tokenstorage.go
··· 1 - package oauth 2 - 3 - import ( 4 - "crypto/ecdsa" 5 - "crypto/x509" 6 - "encoding/json" 7 - "encoding/pem" 8 - "fmt" 9 - "os" 10 - "path/filepath" 11 - "sync" 12 - "time" 13 - ) 14 - 15 - // RefreshTokenEntry represents a stored refresh token for a user 16 - type RefreshTokenEntry struct { 17 - RefreshToken string `json:"refresh_token"` 18 - DPoPKeyPEM string `json:"dpop_key_pem"` 19 - PDS string `json:"pds_endpoint"` 20 - Handle string `json:"handle"` 21 - CreatedAt time.Time `json:"created_at"` 22 - LastRefresh time.Time `json:"last_refreshed"` 23 - } 24 - 25 - // RefreshTokenStorage manages persistent storage of refresh tokens 26 - type RefreshTokenStorage struct { 27 - path string 28 - tokens map[string]*RefreshTokenEntry 29 - mu sync.RWMutex 30 - } 31 - 32 - // StorageData represents the JSON structure stored on disk 33 - type StorageData struct { 34 - RefreshTokens map[string]*RefreshTokenEntry `json:"refresh_tokens"` 35 - } 36 - 37 - // NewRefreshTokenStorage creates a new refresh token storage 38 - func NewRefreshTokenStorage(path string) (*RefreshTokenStorage, error) { 39 - storage := &RefreshTokenStorage{ 40 - path: path, 41 - tokens: make(map[string]*RefreshTokenEntry), 42 - } 43 - 44 - // Load existing tokens if file exists 45 - if err := storage.load(); err != nil { 46 - if !os.IsNotExist(err) { 47 - return nil, fmt.Errorf("failed to load tokens: %w", err) 48 - } 49 - // File doesn't exist yet, that's ok 50 - } 51 - 52 - return storage, nil 53 - } 54 - 55 - // GetDefaultPath returns the default storage path 56 - func GetDefaultPath() (string, error) { 57 - homeDir, err := os.UserHomeDir() 58 - if err != nil { 59 - return "", fmt.Errorf("failed to get home directory: %w", err) 60 - } 61 - 62 - atcrDir := filepath.Join(homeDir, ".atcr") 63 - if err := os.MkdirAll(atcrDir, 0700); err != nil { 64 - return "", fmt.Errorf("failed to create .atcr directory: %w", err) 65 - } 66 - 67 - return filepath.Join(atcrDir, "appview-tokens.json"), nil 68 - } 69 - 70 - // Store saves a refresh token for a DID 71 - func (s *RefreshTokenStorage) Store(did string, entry *RefreshTokenEntry) error { 72 - s.mu.Lock() 73 - defer s.mu.Unlock() 74 - 75 - s.tokens[did] = entry 76 - return s.save() 77 - } 78 - 79 - // Get retrieves a refresh token for a DID 80 - func (s *RefreshTokenStorage) Get(did string) (*RefreshTokenEntry, error) { 81 - s.mu.RLock() 82 - defer s.mu.RUnlock() 83 - 84 - entry, ok := s.tokens[did] 85 - if !ok { 86 - return nil, fmt.Errorf("no refresh token found for DID: %s", did) 87 - } 88 - 89 - return entry, nil 90 - } 91 - 92 - // Delete removes a refresh token for a DID 93 - func (s *RefreshTokenStorage) Delete(did string) error { 94 - s.mu.Lock() 95 - defer s.mu.Unlock() 96 - 97 - delete(s.tokens, did) 98 - return s.save() 99 - } 100 - 101 - // List returns all stored DIDs 102 - func (s *RefreshTokenStorage) List() []string { 103 - s.mu.RLock() 104 - defer s.mu.RUnlock() 105 - 106 - dids := make([]string, 0, len(s.tokens)) 107 - for did := range s.tokens { 108 - dids = append(dids, did) 109 - } 110 - return dids 111 - } 112 - 113 - // GetDPoPKey retrieves and parses the DPoP private key for a DID 114 - func (s *RefreshTokenStorage) GetDPoPKey(did string) (*ecdsa.PrivateKey, error) { 115 - entry, err := s.Get(did) 116 - if err != nil { 117 - return nil, err 118 - } 119 - 120 - // Parse PEM encoded private key 121 - block, _ := pem.Decode([]byte(entry.DPoPKeyPEM)) 122 - if block == nil { 123 - return nil, fmt.Errorf("failed to parse PEM block") 124 - } 125 - 126 - // Parse EC private key 127 - key, err := x509.ParseECPrivateKey(block.Bytes) 128 - if err != nil { 129 - return nil, fmt.Errorf("failed to parse EC private key: %w", err) 130 - } 131 - 132 - return key, nil 133 - } 134 - 135 - // UpdateLastRefresh updates the last refresh timestamp for a DID 136 - func (s *RefreshTokenStorage) UpdateLastRefresh(did string) error { 137 - s.mu.Lock() 138 - defer s.mu.Unlock() 139 - 140 - entry, ok := s.tokens[did] 141 - if !ok { 142 - return fmt.Errorf("no refresh token found for DID: %s", did) 143 - } 144 - 145 - entry.LastRefresh = time.Now() 146 - return s.save() 147 - } 148 - 149 - // load reads tokens from disk 150 - func (s *RefreshTokenStorage) load() error { 151 - data, err := os.ReadFile(s.path) 152 - if err != nil { 153 - return err 154 - } 155 - 156 - var storageData StorageData 157 - if err := json.Unmarshal(data, &storageData); err != nil { 158 - return fmt.Errorf("failed to parse token storage: %w", err) 159 - } 160 - 161 - if storageData.RefreshTokens != nil { 162 - s.tokens = storageData.RefreshTokens 163 - } 164 - 165 - return nil 166 - } 167 - 168 - // save writes tokens to disk 169 - func (s *RefreshTokenStorage) save() error { 170 - storageData := StorageData{ 171 - RefreshTokens: s.tokens, 172 - } 173 - 174 - data, err := json.MarshalIndent(storageData, "", " ") 175 - if err != nil { 176 - return fmt.Errorf("failed to marshal tokens: %w", err) 177 - } 178 - 179 - // Write with restrictive permissions 180 - if err := os.WriteFile(s.path, data, 0600); err != nil { 181 - return fmt.Errorf("failed to write tokens: %w", err) 182 - } 183 - 184 - return nil 185 - } 186 - 187 - // EncodeDPoPKey encodes an ECDSA private key to PEM format 188 - func EncodeDPoPKey(key *ecdsa.PrivateKey) (string, error) { 189 - keyBytes, err := x509.MarshalECPrivateKey(key) 190 - if err != nil { 191 - return "", fmt.Errorf("failed to marshal private key: %w", err) 192 - } 193 - 194 - block := &pem.Block{ 195 - Type: "EC PRIVATE KEY", 196 - Bytes: keyBytes, 197 - } 198 - 199 - return string(pem.EncodeToMemory(block)), nil 200 - }
-154
pkg/auth/oauth/transport.go
··· 1 - package oauth 2 - 3 - import ( 4 - "crypto/ecdsa" 5 - "crypto/sha256" 6 - "encoding/base64" 7 - "fmt" 8 - "net/http" 9 - "sync" 10 - "time" 11 - 12 - "github.com/AxisCommunications/go-dpop" 13 - "github.com/golang-jwt/jwt/v5" 14 - "github.com/google/uuid" 15 - ) 16 - 17 - // DPoPTransport is an HTTP RoundTripper that adds DPoP headers to requests 18 - type DPoPTransport struct { 19 - base http.RoundTripper 20 - dpopKey *ecdsa.PrivateKey 21 - accessToken string // For computing "ath" claim 22 - nonce string 23 - mu sync.RWMutex // Protects nonce 24 - } 25 - 26 - // NewDPoPTransport creates a new DPoP transport with the given private key 27 - func NewDPoPTransport(base http.RoundTripper, dpopKey *ecdsa.PrivateKey) *DPoPTransport { 28 - if base == nil { 29 - base = http.DefaultTransport 30 - } 31 - return &DPoPTransport{ 32 - base: base, 33 - dpopKey: dpopKey, 34 - } 35 - } 36 - 37 - // RoundTrip implements http.RoundTripper 38 - func (t *DPoPTransport) RoundTrip(req *http.Request) (*http.Response, error) { 39 - // Clone the request to avoid modifying the original 40 - reqCopy := req.Clone(req.Context()) 41 - 42 - // Generate and add DPoP proof 43 - if err := t.addDPoPHeader(reqCopy); err != nil { 44 - return nil, fmt.Errorf("failed to add DPoP header: %w", err) 45 - } 46 - 47 - // Execute the request 48 - resp, err := t.base.RoundTrip(reqCopy) 49 - if err != nil { 50 - return nil, err 51 - } 52 - 53 - // Check for DPoP nonce in response 54 - if nonce := resp.Header.Get("DPoP-Nonce"); nonce != "" { 55 - t.mu.Lock() 56 - t.nonce = nonce 57 - t.mu.Unlock() 58 - } 59 - 60 - // If we get 401 with use_dpop_nonce error, retry with nonce 61 - if resp.StatusCode == http.StatusUnauthorized { 62 - wwwAuth := resp.Header.Get("WWW-Authenticate") 63 - if nonce := resp.Header.Get("DPoP-Nonce"); nonce != "" && wwwAuth != "" { 64 - // Update nonce and retry 65 - t.mu.Lock() 66 - t.nonce = nonce 67 - t.mu.Unlock() 68 - 69 - // Close the first response 70 - resp.Body.Close() 71 - 72 - // Retry with new nonce 73 - reqRetry := req.Clone(req.Context()) 74 - if err := t.addDPoPHeader(reqRetry); err != nil { 75 - return nil, fmt.Errorf("failed to add DPoP header on retry: %w", err) 76 - } 77 - return t.base.RoundTrip(reqRetry) 78 - } 79 - } 80 - 81 - return resp, nil 82 - } 83 - 84 - // addDPoPHeader generates and adds a DPoP proof header to the request 85 - func (t *DPoPTransport) addDPoPHeader(req *http.Request) error { 86 - // Read current nonce and access token 87 - t.mu.RLock() 88 - nonce := t.nonce 89 - accessToken := t.accessToken 90 - t.mu.RUnlock() 91 - 92 - // Create DPoP proof claims 93 - claims := &dpop.ProofTokenClaims{ 94 - RegisteredClaims: &jwt.RegisteredClaims{ 95 - ID: uuid.New().String(), 96 - IssuedAt: jwt.NewNumericDate(time.Now()), 97 - }, 98 - Method: dpop.HTTPVerb(req.Method), 99 - URL: req.URL.Scheme + "://" + req.URL.Host + req.URL.Path, 100 - } 101 - 102 - // Add nonce if we have one 103 - if nonce != "" { 104 - claims.Nonce = nonce 105 - } 106 - 107 - // Add "ath" (access token hash) if we have an access token 108 - // This is required when using DPoP with an access token 109 - if accessToken != "" { 110 - // Compute SHA-256 hash of the access token 111 - hash := sha256.Sum256([]byte(accessToken)) 112 - // Base64url encode the hash (without padding) 113 - ath := base64.RawURLEncoding.EncodeToString(hash[:]) 114 - claims.AccessTokenHash = ath 115 - } 116 - 117 - // Generate DPoP proof 118 - // go-dpop automatically adds the JWK to the header 119 - proofString, err := dpop.Create(jwt.SigningMethodES256, claims, t.dpopKey) 120 - if err != nil { 121 - return fmt.Errorf("failed to create DPoP proof: %w", err) 122 - } 123 - 124 - // Add DPoP header 125 - req.Header.Set("DPoP", proofString) 126 - proofPreview := proofString 127 - if len(proofPreview) > 50 { 128 - proofPreview = proofPreview[:50] 129 - } 130 - fmt.Printf("DEBUG [oauth/transport]: Added DPoP proof for %s %s (proof_length=%d, first_50=%q)\n", req.Method, req.URL.String(), len(proofString), proofPreview) 131 - 132 - return nil 133 - } 134 - 135 - // SetNonce manually sets the DPoP nonce (useful for initial requests) 136 - func (t *DPoPTransport) SetNonce(nonce string) { 137 - t.mu.Lock() 138 - defer t.mu.Unlock() 139 - t.nonce = nonce 140 - } 141 - 142 - // GetNonce returns the current DPoP nonce 143 - func (t *DPoPTransport) GetNonce() string { 144 - t.mu.RLock() 145 - defer t.mu.RUnlock() 146 - return t.nonce 147 - } 148 - 149 - // SetAccessToken sets the access token for computing "ath" claim 150 - func (t *DPoPTransport) SetAccessToken(token string) { 151 - t.mu.Lock() 152 - defer t.mu.Unlock() 153 - t.accessToken = token 154 - }
+5 -3
pkg/middleware/registry.go
··· 117 117 118 118 if globalRefresher != nil { 119 119 // Try OAuth flow first 120 - accessToken, dpopKey, dpopTransport, err := globalRefresher.GetAccessToken(ctx, did) 120 + session, err := globalRefresher.GetSession(ctx, did) 121 121 if err == nil { 122 - // OAuth token available - use cached DPoP transport (preserves nonce) 122 + // OAuth session available 123 + accessToken, _ := session.GetHostAccessData() 124 + httpClient := session.APIClient().Client 123 125 fmt.Printf("DEBUG [registry/middleware]: Using OAuth access token for DID=%s (length=%d, first_20=%q)\n", did, len(accessToken), accessToken[:min(20, len(accessToken))]) 124 - atprotoClient = atproto.NewClientWithDPoP(pdsEndpoint, did, accessToken, dpopKey, dpopTransport) 126 + atprotoClient = atproto.NewClientWithHTTPClient(pdsEndpoint, did, accessToken, httpClient) 125 127 } else { 126 128 fmt.Printf("DEBUG [registry/middleware]: OAuth refresh failed for DID=%s: %v, falling back to Basic Auth\n", did, err) 127 129 }