Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol
diffdown.com
1package atproto
2
3import (
4 "encoding/json"
5 "fmt"
6 "io"
7 "log"
8 "net/http"
9 "net/url"
10 "os"
11 "strings"
12 "time"
13
14 "github.com/limeleaf/diffdown/internal/atproto/dpop"
15 "github.com/limeleaf/diffdown/internal/db"
16 "github.com/limeleaf/diffdown/internal/model"
17)
18
19// RefreshAccessToken refreshes the access token for the given ATProto session.
20func RefreshAccessToken(database *db.DB, session *model.ATProtoSession) error {
21 kp, err := dpop.UnmarshalPrivate([]byte(session.DPoPKeyJWK))
22 if err != nil {
23 return fmt.Errorf("unmarshal DPoP key: %w", err)
24 }
25
26 base := os.Getenv("DIFFDOWN_BASE_URL")
27 if base == "" {
28 base = "http://127.0.0.1:8080"
29 }
30 clientID := strings.TrimRight(base, "/") + "/client-metadata.json"
31
32 params := url.Values{
33 "grant_type": {"refresh_token"},
34 "refresh_token": {session.RefreshToken},
35 "client_id": {clientID},
36 }
37
38 proof, err := kp.Proof("POST", session.TokenEndpoint, session.DPoPNonce, "")
39 if err != nil {
40 return fmt.Errorf("build DPoP proof: %w", err)
41 }
42
43 req, _ := http.NewRequest("POST", session.TokenEndpoint, strings.NewReader(params.Encode()))
44 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
45 req.Header.Set("DPoP", proof)
46
47 resp, err := http.DefaultClient.Do(req)
48 if err != nil {
49 return fmt.Errorf("refresh request: %w", err)
50 }
51 defer resp.Body.Close()
52
53 // Handle nonce retry
54 newNonce := resp.Header.Get("DPoP-Nonce")
55 if resp.StatusCode == http.StatusBadRequest && newNonce != "" {
56 proof, err = kp.Proof("POST", session.TokenEndpoint, newNonce, "")
57 if err != nil {
58 return fmt.Errorf("build DPoP proof (retry): %w", err)
59 }
60 req2, _ := http.NewRequest("POST", session.TokenEndpoint, strings.NewReader(params.Encode()))
61 req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
62 req2.Header.Set("DPoP", proof)
63 resp.Body.Close()
64 resp, err = http.DefaultClient.Do(req2)
65 if err != nil {
66 return fmt.Errorf("refresh retry: %w", err)
67 }
68 defer resp.Body.Close()
69 newNonce = resp.Header.Get("DPoP-Nonce")
70 }
71
72 if resp.StatusCode != http.StatusOK {
73 body, _ := io.ReadAll(resp.Body)
74 return fmt.Errorf("refresh failed (HTTP %d): %s", resp.StatusCode, body)
75 }
76
77 var tokenBody struct {
78 AccessToken string `json:"access_token"`
79 RefreshToken string `json:"refresh_token"`
80 ExpiresIn int `json:"expires_in"`
81 }
82 if err := json.NewDecoder(resp.Body).Decode(&tokenBody); err != nil {
83 return fmt.Errorf("decode refresh response: %w", err)
84 }
85
86 if newNonce == "" {
87 newNonce = session.DPoPNonce
88 }
89
90 expiresAt := time.Now().Add(time.Duration(tokenBody.ExpiresIn) * time.Second)
91 if err := database.UpdateATProtoTokens(session.UserID, tokenBody.AccessToken, tokenBody.RefreshToken, newNonce, expiresAt); err != nil {
92 return fmt.Errorf("update tokens in DB: %w", err)
93 }
94
95 // Update in-memory session
96 session.AccessToken = tokenBody.AccessToken
97 session.RefreshToken = tokenBody.RefreshToken
98 session.DPoPNonce = newNonce
99 session.ExpiresAt = expiresAt
100
101 log.Printf("ATProto: refreshed token for user %s", session.UserID)
102 return nil
103}