A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
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 }