···14141515// Helper for resolving OAuth documents from the public web: client metadata, auth server metadata, etc.
1616//
1717-// NOTE: will probably want to add flexible caching to this interface, and that might mean turning it in to an interface.
1717+// NOTE: will probably want to add flexible caching to this interface, and that may mean turning it in to an interface.
1818type Resolver struct {
1919 Client *http.Client
2020 UserAgent string
+203
atproto/auth/oauth/session.go
···11+package oauth
22+33+import (
44+ "bytes"
55+ "context"
66+ "encoding/json"
77+ "fmt"
88+ "log/slog"
99+ "net/http"
1010+ "sync"
1111+ "time"
1212+1313+ "github.com/bluesky-social/indigo/atproto/crypto"
1414+ "github.com/bluesky-social/indigo/atproto/syntax"
1515+1616+ "github.com/golang-jwt/jwt/v5"
1717+ "github.com/google/go-querystring/query"
1818+)
1919+2020+type RefreshCallback = func(ctx context.Context, data SessionData)
2121+2222+type Session struct {
2323+ // HTTP client used for token refresh requests
2424+ Client *http.Client
2525+2626+ Config *ClientConfig
2727+ Data *SessionData
2828+ DpopPrivateKey crypto.PrivateKey
2929+3030+ RefreshCallback RefreshCallback
3131+3232+ // Lock which protects concurrent access to session data (eg, access and refresh tokens)
3333+ lk sync.RWMutex
3434+}
3535+3636+func (sess *Session) RefreshTokens(ctx context.Context) error {
3737+3838+ // TODO: assuming confidential client
3939+ clientAssertion, err := sess.Config.NewAssertionJWT(sess.Data.AuthServerURL)
4040+ if err != nil {
4141+ return err
4242+ }
4343+4444+ body := RefreshTokenRequest{
4545+ ClientID: sess.Config.ClientID,
4646+ GrantType: "authorization_code",
4747+ RefreshToken: sess.Data.RefreshToken,
4848+ ClientAssertionType: &CLIENT_ASSERTION_JWT_BEARER,
4949+ ClientAssertion: &clientAssertion,
5050+ }
5151+5252+ vals, err := query.Values(body)
5353+ if err != nil {
5454+ return err
5555+ }
5656+ bodyBytes := []byte(vals.Encode())
5757+5858+ // XXX: persist this back to the data?
5959+ dpopServerNonce := sess.Data.DpopAuthServerNonce
6060+ tokenURL := sess.Data.AuthServerTokenEndpoint
6161+6262+ var resp *http.Response
6363+ for range 2 {
6464+ dpopJWT, err := NewDPoPJWT("POST", tokenURL, dpopServerNonce, sess.DpopPrivateKey)
6565+ if err != nil {
6666+ return err
6767+ }
6868+6969+ req, err := http.NewRequestWithContext(ctx, "POST", tokenURL, bytes.NewBuffer(bodyBytes))
7070+ if err != nil {
7171+ return err
7272+ }
7373+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
7474+ req.Header.Set("DPoP", dpopJWT)
7575+7676+ resp, err = sess.Client.Do(req)
7777+ if err != nil {
7878+ return err
7979+ }
8080+8181+ // check if a nonce was provided
8282+ dpopServerNonce = resp.Header.Get("DPoP-Nonce")
8383+ if resp.StatusCode == 400 && dpopServerNonce != "" {
8484+ // TODO: also check that body is JSON with an 'error' string field value of 'use_dpop_nonce'
8585+ var errResp map[string]any
8686+ if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil {
8787+ slog.Warn("initial token request failed", "authServer", tokenURL, "err", err, "statusCode", resp.StatusCode)
8888+ } else {
8989+ slog.Warn("initial token request failed", "authServer", tokenURL, "resp", errResp, "statusCode", resp.StatusCode)
9090+ }
9191+9292+ // loop around try again
9393+ resp.Body.Close()
9494+ continue
9595+ }
9696+ // otherwise process result
9797+ break
9898+ }
9999+100100+ defer resp.Body.Close()
101101+ if resp.StatusCode != 200 {
102102+ var errResp map[string]any
103103+ if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil {
104104+ slog.Warn("initial token request failed", "authServer", tokenURL, "err", err, "statusCode", resp.StatusCode)
105105+ } else {
106106+ slog.Warn("initial token request failed", "authServer", tokenURL, "resp", errResp, "statusCode", resp.StatusCode)
107107+ }
108108+ return fmt.Errorf("initial token request failed: HTTP %d", resp.StatusCode)
109109+ }
110110+111111+ var tokenResp TokenResponse
112112+ if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
113113+ return fmt.Errorf("token response failed to decode: %w", err)
114114+ }
115115+ // XXX: more validation of response?
116116+117117+ sess.Data.AccessToken = tokenResp.AccessToken
118118+ sess.Data.RefreshToken = tokenResp.RefreshToken
119119+120120+ return nil
121121+}
122122+123123+func (sess *Session) NewAccessDPoP(method, reqURL string) (string, error) {
124124+125125+ ath := S256CodeChallenge(sess.Data.AccessToken)
126126+ claims := dpopClaims{
127127+ HTTPMethod: method,
128128+ TargetURI: reqURL,
129129+ AccessTokenHash: &ath,
130130+ RegisteredClaims: jwt.RegisteredClaims{
131131+ Issuer: sess.Data.AuthServerURL,
132132+ ID: randomNonce(),
133133+ IssuedAt: jwt.NewNumericDate(time.Now()),
134134+ ExpiresAt: jwt.NewNumericDate(time.Now().Add(JWT_EXPIRATION_DURATION)),
135135+ },
136136+ }
137137+ if sess.Data.DpopHostNonce != "" {
138138+ claims.Nonce = &sess.Data.DpopHostNonce
139139+ }
140140+141141+ keyMethod, err := keySigningMethod(sess.DpopPrivateKey)
142142+ if err != nil {
143143+ return "", err
144144+ }
145145+146146+ // TODO: parse/cache this elsewhere
147147+ pub, err := sess.DpopPrivateKey.PublicKey()
148148+ if err != nil {
149149+ return "", err
150150+ }
151151+ pubJWK, err := pub.JWK()
152152+ if err != nil {
153153+ return "", err
154154+ }
155155+156156+ token := jwt.NewWithClaims(keyMethod, claims)
157157+ token.Header["typ"] = "dpop+jwt"
158158+ token.Header["jwk"] = pubJWK
159159+ return token.SignedString(sess.DpopPrivateKey)
160160+}
161161+162162+func (sess *Session) DoWithAuth(c *http.Client, req *http.Request, endpoint syntax.NSID) (*http.Response, error) {
163163+164164+ // XXX: copy URL and strip query params
165165+ u := req.URL.String()
166166+167167+ dpopServerNonce := sess.Data.DpopHostNonce
168168+ var resp *http.Response
169169+ for range 2 {
170170+ dpopJWT, err := sess.NewAccessDPoP(req.Method, u)
171171+ if err != nil {
172172+ return nil, err
173173+ }
174174+ req.Header.Set("Authorization", fmt.Sprintf("DPoP %s", sess.Data.AccessToken))
175175+ req.Header.Set("DPoP", dpopJWT)
176176+177177+ resp, err = c.Do(req)
178178+ if err != nil {
179179+ return nil, err
180180+ }
181181+182182+ // check if a nonce was provided
183183+ dpopServerNonce = resp.Header.Get("DPoP-Nonce")
184184+ if resp.StatusCode == 400 && dpopServerNonce != "" {
185185+ // TODO: also check that body is JSON with an 'error' string field value of 'use_dpop_nonce'
186186+ var errResp map[string]any
187187+ if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil {
188188+ slog.Warn("authorized request failed", "url", u, "err", err, "statusCode", resp.StatusCode)
189189+ } else {
190190+ slog.Warn("authorized request failed", "url", u, "resp", errResp, "statusCode", resp.StatusCode)
191191+ }
192192+193193+ // XXX: doesn't really work, body might be drained second time
194194+ // loop around try again
195195+ resp.Body.Close()
196196+ continue
197197+ }
198198+ // otherwise process result
199199+ break
200200+ }
201201+ // TODO: check for auth-specific errors, and return them as err
202202+ return resp, nil
203203+}