Stateless auth proxy that converts AT Protocol native apps from public to confidential OAuth clients. Deploy once, get 180-day refresh tokens instead of 24-hour ones.
9
fork

Configure Feed

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

Project Scaffolding

+652
+23
.gitignore
··· 1 + # Binary 2 + atproto-auth-proxy 3 + 4 + # Test binary 5 + *.test 6 + 7 + # Coverage 8 + coverage.out 9 + 10 + # IDE 11 + .idea/ 12 + .vscode/ 13 + *.swp 14 + *.swo 15 + *~ 16 + 17 + # OS 18 + .DS_Store 19 + Thumbs.db 20 + 21 + # Environment / secrets 22 + .env 23 + *.pem
+11
Dockerfile
··· 1 + FROM golang:1.26 AS builder 2 + WORKDIR /src 3 + COPY go.mod go.sum ./ 4 + RUN go mod download 5 + COPY . . 6 + RUN CGO_ENABLED=0 GOOS=linux go build -o /out/atproto-auth-proxy . 7 + 8 + FROM gcr.io/distroless/base-debian12 9 + COPY --from=builder /out/atproto-auth-proxy /app/atproto-auth-proxy 10 + EXPOSE 8080 11 + ENTRYPOINT ["/app/atproto-auth-proxy"]
+21
LICENSE
··· 1 + MIT License 2 + 3 + Copyright (c) 2026 SparrowTek 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+34
assertion.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "time" 6 + 7 + "github.com/google/uuid" 8 + "github.com/lestrrat-go/jwx/v2/jwa" 9 + "github.com/lestrrat-go/jwx/v2/jwk" 10 + "github.com/lestrrat-go/jwx/v2/jwt" 11 + ) 12 + 13 + func GenerateClientAssertion(signingKey jwk.Key, clientID string, audience string) (string, error) { 14 + now := time.Now() 15 + 16 + token, err := jwt.NewBuilder(). 17 + Issuer(clientID). 18 + Subject(clientID). 19 + Audience([]string{audience}). 20 + JwtID(uuid.New().String()). 21 + IssuedAt(now). 22 + Expiration(now.Add(60 * time.Second)). 23 + Build() 24 + if err != nil { 25 + return "", fmt.Errorf("failed to build JWT: %w", err) 26 + } 27 + 28 + signed, err := jwt.Sign(token, jwt.WithKey(jwa.ES256, signingKey)) 29 + if err != nil { 30 + return "", fmt.Errorf("failed to sign JWT: %w", err) 31 + } 32 + 33 + return string(signed), nil 34 + }
+49
config.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + ) 7 + 8 + type Config struct { 9 + PrivateKeyPEM string 10 + ClientID string 11 + KeyID string 12 + Bind string 13 + AllowedOrigins string 14 + } 15 + 16 + func LoadConfig() (*Config, error) { 17 + privateKey := os.Getenv("AUTH_PRIVATE_KEY") 18 + if privateKey == "" { 19 + return nil, fmt.Errorf("AUTH_PRIVATE_KEY is required") 20 + } 21 + 22 + clientID := os.Getenv("AUTH_CLIENT_ID") 23 + if clientID == "" { 24 + return nil, fmt.Errorf("AUTH_CLIENT_ID is required") 25 + } 26 + 27 + keyID := os.Getenv("AUTH_KEY_ID") 28 + if keyID == "" { 29 + keyID = "atproto-auth-1" 30 + } 31 + 32 + bind := os.Getenv("AUTH_BIND") 33 + if bind == "" { 34 + bind = ":8080" 35 + } 36 + 37 + allowedOrigins := os.Getenv("AUTH_ALLOWED_ORIGINS") 38 + if allowedOrigins == "" { 39 + allowedOrigins = "*" 40 + } 41 + 42 + return &Config{ 43 + PrivateKeyPEM: privateKey, 44 + ClientID: clientID, 45 + KeyID: keyID, 46 + Bind: bind, 47 + AllowedOrigins: allowedOrigins, 48 + }, nil 49 + }
+21
go.mod
··· 1 + module tangled.org/sparrowtek.com/atproto-auth-proxy 2 + 3 + go 1.26.1 4 + 5 + require ( 6 + github.com/google/uuid v1.6.0 7 + github.com/lestrrat-go/jwx/v2 v2.1.6 8 + ) 9 + 10 + require ( 11 + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect 12 + github.com/goccy/go-json v0.10.3 // indirect 13 + github.com/lestrrat-go/blackmagic v1.0.3 // indirect 14 + github.com/lestrrat-go/httpcc v1.0.1 // indirect 15 + github.com/lestrrat-go/httprc v1.0.6 // indirect 16 + github.com/lestrrat-go/iter v1.0.2 // indirect 17 + github.com/lestrrat-go/option v1.0.1 // indirect 18 + github.com/segmentio/asm v1.2.0 // indirect 19 + golang.org/x/crypto v0.32.0 // indirect 20 + golang.org/x/sys v0.31.0 // indirect 21 + )
+38
go.sum
··· 1 + github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 + github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 + github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= 5 + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= 6 + github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= 7 + github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 8 + github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 9 + github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 10 + github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs= 11 + github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= 12 + github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= 13 + github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= 14 + github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= 15 + github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= 16 + github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= 17 + github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= 18 + github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA= 19 + github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= 20 + github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= 21 + github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 22 + github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 23 + github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 24 + github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= 25 + github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= 26 + github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 27 + github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 28 + github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 29 + github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 30 + github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 31 + golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= 32 + golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= 33 + golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 34 + golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 35 + gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 36 + gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 37 + gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 38 + gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+8
handler_health.go
··· 1 + package main 2 + 3 + import "net/http" 4 + 5 + func HandleHealth(w http.ResponseWriter, r *http.Request) { 6 + w.Header().Set("Content-Type", "application/json") 7 + w.Write([]byte(`{"status":"ok"}`)) 8 + }
+11
handler_jwks.go
··· 1 + package main 2 + 3 + import "net/http" 4 + 5 + func HandleJWKS(jwksJSON []byte) http.HandlerFunc { 6 + return func(w http.ResponseWriter, r *http.Request) { 7 + w.Header().Set("Content-Type", "application/json") 8 + w.Header().Set("Cache-Control", "public, max-age=3600") 9 + w.Write(jwksJSON) 10 + } 11 + }
+68
handler_par.go
··· 1 + package main 2 + 3 + import ( 4 + "encoding/json" 5 + "log" 6 + "net/http" 7 + "net/url" 8 + 9 + "github.com/lestrrat-go/jwx/v2/jwk" 10 + ) 11 + 12 + type parRequest struct { 13 + PAREndpoint string `json:"par_endpoint"` 14 + LoginHint string `json:"login_hint,omitempty"` 15 + Scope string `json:"scope"` 16 + CodeChallenge string `json:"code_challenge"` 17 + CodeChallengeMethod string `json:"code_challenge_method"` 18 + State string `json:"state"` 19 + RedirectURI string `json:"redirect_uri"` 20 + } 21 + 22 + func HandlePAR(signingKey jwk.Key, clientID string) http.HandlerFunc { 23 + return func(w http.ResponseWriter, r *http.Request) { 24 + var req parRequest 25 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 26 + http.Error(w, `{"error":"invalid_request","error_description":"invalid JSON body"}`, http.StatusBadRequest) 27 + return 28 + } 29 + 30 + if req.PAREndpoint == "" { 31 + http.Error(w, `{"error":"invalid_request","error_description":"par_endpoint is required"}`, http.StatusBadRequest) 32 + return 33 + } 34 + 35 + if err := ValidateTokenEndpoint(req.PAREndpoint); err != nil { 36 + http.Error(w, `{"error":"invalid_request","error_description":"invalid par_endpoint"}`, http.StatusBadRequest) 37 + return 38 + } 39 + 40 + assertion, err := GenerateClientAssertion(signingKey, clientID, req.PAREndpoint) 41 + if err != nil { 42 + log.Printf("failed to generate client assertion: %v", err) 43 + http.Error(w, `{"error":"server_error","error_description":"failed to generate client assertion"}`, http.StatusInternalServerError) 44 + return 45 + } 46 + 47 + params := url.Values{} 48 + params.Set("response_type", "code") 49 + params.Set("client_id", clientID) 50 + params.Set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer") 51 + params.Set("client_assertion", assertion) 52 + params.Set("scope", req.Scope) 53 + params.Set("code_challenge", req.CodeChallenge) 54 + params.Set("code_challenge_method", req.CodeChallengeMethod) 55 + params.Set("state", req.State) 56 + params.Set("redirect_uri", req.RedirectURI) 57 + 58 + if req.LoginHint != "" { 59 + params.Set("login_hint", req.LoginHint) 60 + } 61 + 62 + dpopHeader := r.Header.Get("DPoP") 63 + 64 + if err := ProxyRequest(w, req.PAREndpoint, params, dpopHeader); err != nil { 65 + log.Printf("proxy request failed: %v", err) 66 + } 67 + } 68 + }
+76
handler_token.go
··· 1 + package main 2 + 3 + import ( 4 + "encoding/json" 5 + "log" 6 + "net/http" 7 + "net/url" 8 + 9 + "github.com/lestrrat-go/jwx/v2/jwk" 10 + ) 11 + 12 + type tokenRequest struct { 13 + TokenEndpoint string `json:"token_endpoint"` 14 + GrantType string `json:"grant_type"` 15 + Code string `json:"code,omitempty"` 16 + RedirectURI string `json:"redirect_uri,omitempty"` 17 + CodeVerifier string `json:"code_verifier,omitempty"` 18 + RefreshToken string `json:"refresh_token,omitempty"` 19 + } 20 + 21 + func HandleToken(signingKey jwk.Key, clientID string) http.HandlerFunc { 22 + return func(w http.ResponseWriter, r *http.Request) { 23 + var req tokenRequest 24 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 25 + http.Error(w, `{"error":"invalid_request","error_description":"invalid JSON body"}`, http.StatusBadRequest) 26 + return 27 + } 28 + 29 + if req.TokenEndpoint == "" { 30 + http.Error(w, `{"error":"invalid_request","error_description":"token_endpoint is required"}`, http.StatusBadRequest) 31 + return 32 + } 33 + 34 + if req.GrantType == "" { 35 + http.Error(w, `{"error":"invalid_request","error_description":"grant_type is required"}`, http.StatusBadRequest) 36 + return 37 + } 38 + 39 + if err := ValidateTokenEndpoint(req.TokenEndpoint); err != nil { 40 + http.Error(w, `{"error":"invalid_request","error_description":"invalid token_endpoint"}`, http.StatusBadRequest) 41 + return 42 + } 43 + 44 + assertion, err := GenerateClientAssertion(signingKey, clientID, req.TokenEndpoint) 45 + if err != nil { 46 + log.Printf("failed to generate client assertion: %v", err) 47 + http.Error(w, `{"error":"server_error","error_description":"failed to generate client assertion"}`, http.StatusInternalServerError) 48 + return 49 + } 50 + 51 + params := url.Values{} 52 + params.Set("grant_type", req.GrantType) 53 + params.Set("client_id", clientID) 54 + params.Set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer") 55 + params.Set("client_assertion", assertion) 56 + 57 + if req.Code != "" { 58 + params.Set("code", req.Code) 59 + } 60 + if req.RedirectURI != "" { 61 + params.Set("redirect_uri", req.RedirectURI) 62 + } 63 + if req.CodeVerifier != "" { 64 + params.Set("code_verifier", req.CodeVerifier) 65 + } 66 + if req.RefreshToken != "" { 67 + params.Set("refresh_token", req.RefreshToken) 68 + } 69 + 70 + dpopHeader := r.Header.Get("DPoP") 71 + 72 + if err := ProxyRequest(w, req.TokenEndpoint, params, dpopHeader); err != nil { 73 + log.Printf("proxy request failed: %v", err) 74 + } 75 + } 76 + }
+82
keys.go
··· 1 + package main 2 + 3 + import ( 4 + "crypto/ecdsa" 5 + "crypto/x509" 6 + "encoding/json" 7 + "encoding/pem" 8 + "fmt" 9 + 10 + "github.com/lestrrat-go/jwx/v2/jwa" 11 + "github.com/lestrrat-go/jwx/v2/jwk" 12 + ) 13 + 14 + func ParsePrivateKey(pemData string) (*ecdsa.PrivateKey, error) { 15 + block, _ := pem.Decode([]byte(pemData)) 16 + if block == nil { 17 + return nil, fmt.Errorf("failed to decode PEM block") 18 + } 19 + 20 + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) 21 + if err != nil { 22 + return nil, fmt.Errorf("failed to parse private key: %w", err) 23 + } 24 + 25 + ecKey, ok := key.(*ecdsa.PrivateKey) 26 + if !ok { 27 + return nil, fmt.Errorf("key is not an EC private key") 28 + } 29 + 30 + if ecKey.Curve.Params().BitSize != 256 { 31 + return nil, fmt.Errorf("key must be P-256, got %s", ecKey.Curve.Params().Name) 32 + } 33 + 34 + return ecKey, nil 35 + } 36 + 37 + func BuildJWKS(privateKey *ecdsa.PrivateKey, kid string) ([]byte, error) { 38 + pubKey := privateKey.Public() 39 + 40 + jwkKey, err := jwk.FromRaw(pubKey) 41 + if err != nil { 42 + return nil, fmt.Errorf("failed to create JWK from public key: %w", err) 43 + } 44 + 45 + if err := jwkKey.Set(jwk.KeyIDKey, kid); err != nil { 46 + return nil, fmt.Errorf("failed to set kid: %w", err) 47 + } 48 + if err := jwkKey.Set(jwk.AlgorithmKey, jwa.ES256); err != nil { 49 + return nil, fmt.Errorf("failed to set alg: %w", err) 50 + } 51 + if err := jwkKey.Set(jwk.KeyUsageKey, "sig"); err != nil { 52 + return nil, fmt.Errorf("failed to set use: %w", err) 53 + } 54 + 55 + set := jwk.NewSet() 56 + if err := set.AddKey(jwkKey); err != nil { 57 + return nil, fmt.Errorf("failed to add key to set: %w", err) 58 + } 59 + 60 + jsonBytes, err := json.Marshal(set) 61 + if err != nil { 62 + return nil, fmt.Errorf("failed to marshal JWKS: %w", err) 63 + } 64 + 65 + return jsonBytes, nil 66 + } 67 + 68 + func NewSigner(privateKey *ecdsa.PrivateKey, kid string) (jwk.Key, error) { 69 + signingKey, err := jwk.FromRaw(privateKey) 70 + if err != nil { 71 + return nil, fmt.Errorf("failed to create signing key: %w", err) 72 + } 73 + 74 + if err := signingKey.Set(jwk.KeyIDKey, kid); err != nil { 75 + return nil, fmt.Errorf("failed to set kid: %w", err) 76 + } 77 + if err := signingKey.Set(jwk.AlgorithmKey, jwa.ES256); err != nil { 78 + return nil, fmt.Errorf("failed to set alg: %w", err) 79 + } 80 + 81 + return signingKey, nil 82 + }
+72
main.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "log" 6 + "net/http" 7 + "os" 8 + "os/signal" 9 + "syscall" 10 + "time" 11 + ) 12 + 13 + func main() { 14 + cfg, err := LoadConfig() 15 + if err != nil { 16 + log.Fatalf("configuration error: %v", err) 17 + } 18 + 19 + privateKey, err := ParsePrivateKey(cfg.PrivateKeyPEM) 20 + if err != nil { 21 + log.Fatalf("failed to parse private key: %v", err) 22 + } 23 + 24 + jwksJSON, err := BuildJWKS(privateKey, cfg.KeyID) 25 + if err != nil { 26 + log.Fatalf("failed to build JWKS: %v", err) 27 + } 28 + 29 + signingKey, err := NewSigner(privateKey, cfg.KeyID) 30 + if err != nil { 31 + log.Fatalf("failed to create signer: %v", err) 32 + } 33 + 34 + mux := http.NewServeMux() 35 + 36 + mux.HandleFunc("GET /.well-known/jwks.json", HandleJWKS(jwksJSON)) 37 + mux.HandleFunc("POST /oauth/token", HandleToken(signingKey, cfg.ClientID)) 38 + mux.HandleFunc("POST /oauth/par", HandlePAR(signingKey, cfg.ClientID)) 39 + mux.HandleFunc("GET /health", HandleHealth) 40 + 41 + handler := CORSMiddleware(cfg.AllowedOrigins, mux) 42 + 43 + srv := &http.Server{ 44 + Addr: cfg.Bind, 45 + Handler: handler, 46 + ReadTimeout: 10 * time.Second, 47 + WriteTimeout: 30 * time.Second, 48 + IdleTimeout: 60 * time.Second, 49 + } 50 + 51 + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) 52 + defer stop() 53 + 54 + go func() { 55 + log.Printf("atproto-auth-proxy listening on %s", cfg.Bind) 56 + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { 57 + log.Fatalf("server error: %v", err) 58 + } 59 + }() 60 + 61 + <-ctx.Done() 62 + log.Println("shutting down...") 63 + 64 + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 65 + defer cancel() 66 + 67 + if err := srv.Shutdown(shutdownCtx); err != nil { 68 + log.Fatalf("shutdown error: %v", err) 69 + } 70 + 71 + log.Println("server stopped") 72 + }
+19
middleware.go
··· 1 + package main 2 + 3 + import "net/http" 4 + 5 + func CORSMiddleware(allowedOrigins string, next http.Handler) http.Handler { 6 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 7 + w.Header().Set("Access-Control-Allow-Origin", allowedOrigins) 8 + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") 9 + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, DPoP") 10 + w.Header().Set("Access-Control-Expose-Headers", "DPoP-Nonce") 11 + 12 + if r.Method == http.MethodOptions { 13 + w.WriteHeader(http.StatusNoContent) 14 + return 15 + } 16 + 17 + next.ServeHTTP(w, r) 18 + }) 19 + }
+42
proxy.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "io" 6 + "net/http" 7 + "net/url" 8 + "strings" 9 + ) 10 + 11 + func ProxyRequest(w http.ResponseWriter, upstreamURL string, formParams url.Values, dpopHeader string) error { 12 + req, err := http.NewRequest(http.MethodPost, upstreamURL, strings.NewReader(formParams.Encode())) 13 + if err != nil { 14 + return fmt.Errorf("failed to create upstream request: %w", err) 15 + } 16 + 17 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 18 + 19 + if dpopHeader != "" { 20 + req.Header.Set("DPoP", dpopHeader) 21 + } 22 + 23 + resp, err := http.DefaultClient.Do(req) 24 + if err != nil { 25 + return fmt.Errorf("upstream request failed: %w", err) 26 + } 27 + defer resp.Body.Close() 28 + 29 + for key, values := range resp.Header { 30 + for _, value := range values { 31 + w.Header().Add(key, value) 32 + } 33 + } 34 + 35 + w.WriteHeader(resp.StatusCode) 36 + 37 + if _, err := io.Copy(w, resp.Body); err != nil { 38 + return fmt.Errorf("failed to copy response body: %w", err) 39 + } 40 + 41 + return nil 42 + }
+77
validation.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "net" 6 + "net/url" 7 + "sync" 8 + ) 9 + 10 + var ( 11 + validatedHosts sync.Map 12 + ) 13 + 14 + func ValidateTokenEndpoint(endpoint string) error { 15 + u, err := url.Parse(endpoint) 16 + if err != nil { 17 + return fmt.Errorf("invalid URL: %w", err) 18 + } 19 + 20 + if u.Scheme != "https" { 21 + return fmt.Errorf("endpoint must use HTTPS") 22 + } 23 + 24 + host := u.Hostname() 25 + if host == "" { 26 + return fmt.Errorf("endpoint must have a hostname") 27 + } 28 + 29 + if isPrivateHost(host) { 30 + return fmt.Errorf("endpoint must not be a private/localhost address") 31 + } 32 + 33 + if _, ok := validatedHosts.Load(host); ok { 34 + return nil 35 + } 36 + 37 + validatedHosts.Store(host, true) 38 + return nil 39 + } 40 + 41 + func isPrivateHost(host string) bool { 42 + if host == "localhost" { 43 + return true 44 + } 45 + 46 + ip := net.ParseIP(host) 47 + if ip == nil { 48 + return false 49 + } 50 + 51 + privateRanges := []struct { 52 + network *net.IPNet 53 + }{ 54 + {mustParseCIDR("10.0.0.0/8")}, 55 + {mustParseCIDR("172.16.0.0/12")}, 56 + {mustParseCIDR("192.168.0.0/16")}, 57 + {mustParseCIDR("127.0.0.0/8")}, 58 + {mustParseCIDR("::1/128")}, 59 + {mustParseCIDR("fc00::/7")}, 60 + } 61 + 62 + for _, r := range privateRanges { 63 + if r.network.Contains(ip) { 64 + return true 65 + } 66 + } 67 + 68 + return false 69 + } 70 + 71 + func mustParseCIDR(cidr string) *net.IPNet { 72 + _, network, err := net.ParseCIDR(cidr) 73 + if err != nil { 74 + panic(fmt.Sprintf("invalid CIDR: %s", cidr)) 75 + } 76 + return network 77 + }