An implementation of the ATProto statusphere example app but in Go

some improvements to the authflow when creating a new status

+47 -99
-64
auth_handlers.go
··· 1 1 package statusphere 2 2 3 3 import ( 4 - "crypto/sha256" 5 4 _ "embed" 6 - "encoding/base64" 7 - "encoding/json" 8 5 "fmt" 9 6 "log/slog" 10 7 "net/http" 11 8 "net/url" 12 - "time" 13 9 14 - "github.com/golang-jwt/jwt" 15 - "github.com/google/uuid" 16 10 "github.com/gorilla/sessions" 17 - "github.com/lestrrat-go/jwx/v2/jwk" 18 11 "github.com/willdot/statusphere-go/oauth" 19 12 ) 20 13 ··· 195 188 196 189 http.Redirect(w, r, "/", http.StatusFound) 197 190 } 198 - 199 - func pdsDpopJwt(method, url, iss, accessToken, nonce string, privateJwk jwk.Key) (string, error) { 200 - pubJwk, err := privateJwk.PublicKey() 201 - if err != nil { 202 - return "", err 203 - } 204 - 205 - b, err := json.Marshal(pubJwk) 206 - if err != nil { 207 - return "", err 208 - } 209 - 210 - var pubMap map[string]any 211 - if err := json.Unmarshal(b, &pubMap); err != nil { 212 - return "", err 213 - } 214 - 215 - now := time.Now().Unix() 216 - 217 - claims := jwt.MapClaims{ 218 - "iss": iss, 219 - "iat": now, 220 - "exp": now + 30, 221 - "jti": uuid.NewString(), 222 - "htm": method, 223 - "htu": url, 224 - "ath": generateCodeChallenge(accessToken), 225 - } 226 - 227 - if nonce != "" { 228 - claims["nonce"] = nonce 229 - } 230 - 231 - token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) 232 - token.Header["typ"] = "dpop+jwt" 233 - token.Header["alg"] = "ES256" 234 - token.Header["jwk"] = pubMap 235 - 236 - var rawKey any 237 - if err := privateJwk.Raw(&rawKey); err != nil { 238 - return "", err 239 - } 240 - 241 - tokenString, err := token.SignedString(rawKey) 242 - if err != nil { 243 - return "", fmt.Errorf("failed to sign token: %w", err) 244 - } 245 - 246 - return tokenString, nil 247 - } 248 - 249 - func generateCodeChallenge(pkceVerifier string) string { 250 - h := sha256.New() 251 - h.Write([]byte(pkceVerifier)) 252 - hash := h.Sum(nil) 253 - return base64.RawURLEncoding.EncodeToString(hash) 254 - }
+1 -2
go.mod
··· 8 8 github.com/avast/retry-go/v4 v4.6.1 9 9 github.com/bluesky-social/jetstream v0.0.0-20250414024304-d17bd81a945e 10 10 github.com/glebarez/go-sqlite v1.22.0 11 - github.com/golang-jwt/jwt v3.2.2+incompatible 12 - github.com/google/uuid v1.6.0 13 11 github.com/gorilla/sessions v1.4.0 14 12 github.com/haileyok/atproto-oauth-golang v0.0.2 15 13 github.com/joho/godotenv v1.5.1 ··· 29 27 github.com/goccy/go-json v0.10.3 // indirect 30 28 github.com/gogo/protobuf v1.3.2 // indirect 31 29 github.com/golang-jwt/jwt/v5 v5.2.1 // indirect 30 + github.com/google/uuid v1.6.0 // indirect 32 31 github.com/gorilla/securecookie v1.1.2 // indirect 33 32 github.com/gorilla/websocket v1.5.1 // indirect 34 33 github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
-2
go.sum
··· 38 38 github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 39 39 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 40 40 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 41 - github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= 42 - github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= 43 41 github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= 44 42 github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 45 43 github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+4
oauth/service.go
··· 244 244 return s.jwks.public 245 245 } 246 246 247 + func (s *Service) PdsDpopJwt(method, url string, session Session, privateKey jwk.Key) (string, error) { 248 + return atoauth.PdsDpopJwt(method, url, session.AuthserverIss, session.AccessToken, session.DpopPdsNonce, privateKey) 249 + } 250 + 247 251 func (s *Service) makeOAuthRequest(ctx context.Context, did, handle string, dpopPrivateKey jwk.Key) (*atoauth.SendParAuthResponse, *atoauth.OauthAuthorizationMetadata, string, error) { 248 252 service, err := s.resolveService(ctx, did) 249 253 if err != nil {
+42 -31
status.go
··· 27 27 } 28 28 29 29 type CreateRecordResp struct { 30 - URI string `json:"uri"` 30 + URI string `json:"uri"` 31 + ErrStr string `json:"error"` 32 + Message string `json:"message"` 31 33 } 32 34 33 35 func (s *Server) CreateNewStatus(ctx context.Context, oauthsession oauth.Session, status string, createdAt time.Time) (string, error) { 34 - privateJwk, err := oauthsession.CreatePrivateKey() 35 - if err != nil { 36 - return "", fmt.Errorf("create private jwk: %w", err) 37 - } 38 - 39 36 bodyReq := map[string]any{ 40 37 "repo": oauthsession.Did, 41 38 "collection": "xyz.statusphere.status", ··· 50 47 return "", fmt.Errorf("marshal update message request body: %w", err) 51 48 } 52 49 53 - // TODO: redo this loop business 54 - for range 2 { 55 - r := bytes.NewReader(bodyB) 56 - url := fmt.Sprintf("%s/xrpc/com.atproto.repo.createRecord", oauthsession.PdsUrl) 57 - request, err := http.NewRequestWithContext(ctx, "POST", url, r) 58 - if err != nil { 59 - return "", fmt.Errorf("create http request: %w", err) 60 - } 50 + r := bytes.NewReader(bodyB) 51 + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.createRecord", oauthsession.PdsUrl) 52 + request, err := http.NewRequestWithContext(ctx, "POST", url, r) 53 + if err != nil { 54 + return "", fmt.Errorf("create http request: %w", err) 55 + } 56 + 57 + request.Header.Add("Content-Type", "application/json") 58 + request.Header.Add("Accept", "application/json") 59 + request.Header.Set("Authorization", "DPoP "+oauthsession.AccessToken) 61 60 62 - request.Header.Add("Content-Type", "application/json") 63 - request.Header.Add("Accept", "application/json") 61 + privateKey, err := oauthsession.CreatePrivateKey() 62 + if err != nil { 63 + return "", fmt.Errorf("create private key: %w", err) 64 + } 64 65 65 - dpopJwt, err := pdsDpopJwt("POST", url, oauthsession.AuthserverIss, oauthsession.AccessToken, oauthsession.DpopPdsNonce, privateJwk) 66 + // try a maximum of 2 times to make the request. If the first attempt fails because the server returns an unauthorized due to a new use_dpop_nonce being issued, 67 + // then try again. Otherwise just try once. 68 + for range 2 { 69 + dpopJwt, err := s.oauthService.PdsDpopJwt("POST", url, oauthsession, privateKey) 66 70 if err != nil { 67 71 return "", err 68 72 } 69 73 70 74 request.Header.Set("DPoP", dpopJwt) 71 - request.Header.Set("Authorization", "DPoP "+oauthsession.AccessToken) 72 75 73 76 resp, err := s.httpClient.Do(request) 74 77 if err != nil { 75 78 return "", fmt.Errorf("do http request: %w", err) 76 79 } 77 80 defer resp.Body.Close() 81 + 82 + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusBadRequest && resp.StatusCode != http.StatusUnauthorized { 83 + return "", fmt.Errorf("unexpected status code returned: %d", resp.StatusCode) 84 + } 85 + 86 + var result CreateRecordResp 87 + err = decodeResp(resp.Body, &result) 88 + if err != nil { 89 + // just log the error. 90 + // if a HTTP 200 is received then the record has been created and we only use the response URI to make an optimistic write to our DB, so nothing will go wrong here. 91 + // if a HTTP 400 then we can at least log return that it was a bad request. 92 + // if a HTTP 401 we only do something if the error string is use_dpop_nonce 93 + slog.Error("decode response body", "error", err) 94 + } 95 + 96 + slog.Info("resp", "status", resp.StatusCode) 78 97 79 98 if resp.StatusCode == http.StatusOK { 80 - var result CreateRecordResp 81 - err = decodeResp(resp.Body, &result) 82 - if err != nil { 83 - // just log error because we got a 200 indicating that the record was created. If this were to be tried again due to an error 84 - // returned here, there would be duplicate data 85 - slog.Error("decode success response", "error", err) 86 - } 87 99 return result.URI, nil 88 100 } 89 101 90 - var errorResp XRPCError 91 - err = decodeResp(resp.Body, &errorResp) 92 - if err != nil { 93 - return "", fmt.Errorf("decode error resp: %w", err) 102 + if resp.StatusCode == http.StatusBadRequest { 103 + return "", fmt.Errorf("bad request: %s - %s", result.Message, result.ErrStr) 94 104 } 95 105 96 - if resp.StatusCode == 400 || resp.StatusCode == 401 && errorResp.ErrStr == "use_dpop_nonce" { 106 + if resp.StatusCode == http.StatusUnauthorized && result.ErrStr == "use_dpop_nonce" { 97 107 newNonce := resp.Header.Get("DPoP-Nonce") 98 108 oauthsession.DpopPdsNonce = newNonce 99 109 err := s.oauthService.UpdateOAuthSessionDPopPDSNonce(oauthsession.Did, newNonce) 100 110 if err != nil { 111 + // just log the error because we can still proceed without storing it. 101 112 slog.Error("updating oauth session in store with new DPoP PDS nonce", "error", err) 102 113 } 103 114 continue 104 115 } 105 116 106 - slog.Error("got error", "status code", resp.StatusCode, "message", errorResp.Message, "error", errorResp.ErrStr) 117 + return "", fmt.Errorf("received an unauthorized status code and message: %s - %s", result.ErrStr, result.Message) 107 118 } 108 119 109 120 return "", fmt.Errorf("failed to create status record")