this repo has no description
0
fork

Configure Feed

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

OAuth client SDK: Support token revocation via Logout helper (#1156)

Addresses #1135

Revocation has similar properties to session refresh, so I implemented
it in `ClientSession` alongside `RefreshTokens`.

A high-level `Logout` helper has been added to `ClientApp` which both
revokes the tokens against the AS and deletes the session from the app's
session store.

Due to aforementioned similarity between refresh and revocation, I
created the `ClientSession.postToAuthServer` helper to reduce duplicated
logic. (Note that "DPoP retry" logic is still duplicated elsewhere, I
couldn't think of any elegant way to eliminate it)

authored by

David Buchanan and committed by
GitHub
7bec8976 0f22fe8b

+160 -50
+2 -2
atproto/auth/oauth/cmd/oauth-web-demo/main.go
··· 294 294 295 295 func (s *Server) OAuthLogout(w http.ResponseWriter, r *http.Request) { 296 296 297 - // delete session from auth store 297 + // revoke tokens and delete session from auth store 298 298 did, sessionID := s.currentSessionDID(r) 299 299 if did != nil { 300 - if err := s.OAuth.Store.DeleteSession(r.Context(), *did, sessionID); err != nil { 300 + if err := s.OAuth.Logout(r.Context(), *did, sessionID); err != nil { 301 301 slog.Error("failed to delete session", "did", did, "err", err) 302 302 } 303 303 }
+2 -2
atproto/auth/oauth/doc.go
··· 122 122 123 123 The [ClientSession] will handle nonce updates and token refreshes, and persist the results in the [ClientAuthStore]. 124 124 125 - To log out a user, delete their session from the [ClientAuthStore]: 125 + To log out a user, use the [ClientApp.Logout] helper method, which revokes their tokens (if supported by the AS) and deletes their session from the [ClientAuthStore]: 126 126 127 - if err := oauthApp.Store.DeleteSession(r.Context(), did, sessionID); err != nil { 127 + if err := oauthApp.Logout(r.Context(), did, sessionID); err != nil { 128 128 return err 129 129 } 130 130
+47 -19
atproto/auth/oauth/oauth.go
··· 413 413 } 414 414 415 415 parInfo := AuthRequestData{ 416 - State: state, 417 - AuthServerURL: authMeta.Issuer, 418 - Scopes: scopes, 419 - PKCEVerifier: pkceVerifier, 420 - RequestURI: parResp.RequestURI, 421 - AuthServerTokenEndpoint: authMeta.TokenEndpoint, 422 - DPoPAuthServerNonce: dpopServerNonce, 423 - DPoPPrivateKeyMultibase: dpopPrivKey.Multibase(), 416 + State: state, 417 + AuthServerURL: authMeta.Issuer, 418 + Scopes: scopes, 419 + PKCEVerifier: pkceVerifier, 420 + RequestURI: parResp.RequestURI, 421 + AuthServerTokenEndpoint: authMeta.TokenEndpoint, 422 + AuthServerRevocationEndpoint: authMeta.RevocationEndpoint, 423 + DPoPAuthServerNonce: dpopServerNonce, 424 + DPoPPrivateKeyMultibase: dpopPrivKey.Multibase(), 424 425 } 425 426 426 427 return &parInfo, nil ··· 668 669 } 669 670 670 671 sessData := ClientSessionData{ 671 - AccountDID: accountDID, 672 - SessionID: info.State, 673 - HostURL: hostURL, 674 - AuthServerURL: info.AuthServerURL, 675 - AuthServerTokenEndpoint: info.AuthServerTokenEndpoint, 676 - Scopes: strings.Split(tokenResp.Scope, " "), 677 - AccessToken: tokenResp.AccessToken, 678 - RefreshToken: tokenResp.RefreshToken, 679 - DPoPAuthServerNonce: info.DPoPAuthServerNonce, 680 - DPoPHostNonce: info.DPoPAuthServerNonce, // bootstrap host nonce from authserver 681 - DPoPPrivateKeyMultibase: info.DPoPPrivateKeyMultibase, 672 + AccountDID: accountDID, 673 + SessionID: info.State, 674 + HostURL: hostURL, 675 + AuthServerURL: info.AuthServerURL, 676 + AuthServerTokenEndpoint: info.AuthServerTokenEndpoint, 677 + AuthServerRevocationEndpoint: info.AuthServerRevocationEndpoint, 678 + Scopes: strings.Split(tokenResp.Scope, " "), 679 + AccessToken: tokenResp.AccessToken, 680 + RefreshToken: tokenResp.RefreshToken, 681 + DPoPAuthServerNonce: info.DPoPAuthServerNonce, 682 + DPoPHostNonce: info.DPoPAuthServerNonce, // bootstrap host nonce from authserver 683 + DPoPPrivateKeyMultibase: info.DPoPPrivateKeyMultibase, 682 684 } 683 685 if err := app.Store.SaveSession(ctx, sessData); err != nil { 684 686 return nil, err ··· 689 691 } 690 692 return &sessData, nil 691 693 } 694 + 695 + // High-level helper to delete a session, including revoking access/refresh tokens if supported by the AS 696 + func (app *ClientApp) Logout(ctx context.Context, did syntax.DID, sessionID string) error { 697 + sess, err := app.ResumeSession(ctx, did, sessionID) 698 + if err != nil { 699 + return err 700 + } 701 + 702 + // Tell the AS to revoke the tokens, if supported 703 + if sess.Data.AuthServerRevocationEndpoint == "" { 704 + slog.Info("AS does not support token revocation, skipping RevokeSession") 705 + } else { 706 + err = sess.RevokeSession(ctx) 707 + if err != nil { 708 + slog.Warn("error during session revocation", "err", err) 709 + } 710 + } 711 + 712 + // Delete from our own session store 713 + err = app.Store.DeleteSession(ctx, did, sessionID) 714 + if err != nil { 715 + return err 716 + } 717 + 718 + return nil 719 + }
+83 -27
atproto/auth/oauth/session.go
··· 4 4 "bytes" 5 5 "context" 6 6 "encoding/json" 7 + "errors" 7 8 "fmt" 8 9 "log/slog" 9 10 "net/http" ··· 38 39 39 40 // Full token endpoint 40 41 AuthServerTokenEndpoint string `json:"authserver_token_endpoint"` 42 + 43 + // Full revocation endpoint, if it exists 44 + AuthServerRevocationEndpoint string `json:"authserver_revocation_endpoint,omitempty"` 41 45 42 46 // The set of scopes approved for this session (returned in the initial token request) 43 47 Scopes []string `json:"scopes"` ··· 77 81 lk sync.RWMutex 78 82 } 79 83 80 - // Requests new tokens from auth server, and returns the new access token on success. 81 - // 82 - // Internally takes a lock on session data around the entire refresh process, including retries. Persists data using [PersistSessionCallback] if configured. 83 - func (sess *ClientSession) RefreshTokens(ctx context.Context) (string, error) { 84 - sess.lk.Lock() 85 - defer sess.lk.Unlock() 86 - 87 - body := RefreshTokenRequest{ 88 - ClientID: sess.Config.ClientID, 89 - GrantType: "refresh_token", 90 - RefreshToken: sess.Data.RefreshToken, 84 + // Helper method to handle DPoP retries and client assertions (if the client is confidential) 85 + // body object will be url-encoded (expected to be either RefreshTokenRequest or RevocationRequest) 86 + // expects sess.lk to be held by caller 87 + // if a non-nil *http.Response is returned, the caller is responsible for closing the response body 88 + func (sess *ClientSession) postToAuthServer(ctx context.Context, url string, body interface{}) (*http.Response, error) { 89 + vals, err := query.Values(body) 90 + if err != nil { 91 + return nil, err 91 92 } 92 - tokenURL := sess.Data.AuthServerTokenEndpoint 93 - 94 93 if sess.Config.IsConfidential() { 95 94 clientAssertion, err := sess.Config.NewClientAssertion(sess.Data.AuthServerURL) 96 95 if err != nil { 97 - return "", err 96 + return nil, err 98 97 } 99 - body.ClientAssertionType = &ClientAssertionJWTBearer 100 - body.ClientAssertion = &clientAssertion 101 - } 102 - 103 - vals, err := query.Values(body) 104 - if err != nil { 105 - return "", err 98 + vals.Set("client_assertion_type", ClientAssertionJWTBearer) 99 + vals.Set("client_assertion", clientAssertion) 106 100 } 107 101 bodyBytes := []byte(vals.Encode()) 108 102 109 103 var resp *http.Response 110 104 for range 2 { 111 - dpopJWT, err := NewAuthDPoP("POST", sess.Data.AuthServerTokenEndpoint, sess.Data.DPoPAuthServerNonce, sess.DPoPPrivateKey) 105 + dpopJWT, err := NewAuthDPoP("POST", url, sess.Data.DPoPAuthServerNonce, sess.DPoPPrivateKey) 112 106 if err != nil { 113 - return "", err 107 + return nil, err 114 108 } 115 109 116 - req, err := http.NewRequestWithContext(ctx, "POST", tokenURL, bytes.NewBuffer(bodyBytes)) 110 + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(bodyBytes)) 117 111 if err != nil { 118 - return "", err 112 + return nil, err 119 113 } 120 114 req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 121 115 req.Header.Set("DPoP", dpopJWT) 122 116 123 117 resp, err = sess.Client.Do(req) 124 118 if err != nil { 125 - return "", err 119 + return nil, err 126 120 } 127 121 128 122 // always check if a new DPoP nonce was provided, and proactively update session data (even if there was not an explicit error) ··· 140 134 // already updated nonce value above; loop around and try again 141 135 continue 142 136 } 143 - return "", fmt.Errorf("token refresh failed (HTTP %d): %s", resp.StatusCode, reason) 137 + return nil, fmt.Errorf("auth server request failed (HTTP %d): %s", resp.StatusCode, reason) 144 138 } 145 139 146 140 // otherwise process response (success or other error type) 147 141 break 148 142 } 149 143 144 + return resp, nil 145 + } 146 + 147 + // Requests new tokens from auth server, and returns the new access token on success. 148 + // 149 + // Internally takes a lock on session data around the entire refresh process, including retries. Persists data using [PersistSessionCallback] if configured. 150 + func (sess *ClientSession) RefreshTokens(ctx context.Context) (string, error) { 151 + sess.lk.Lock() 152 + defer sess.lk.Unlock() 153 + 154 + body := RefreshTokenRequest{ 155 + ClientID: sess.Config.ClientID, 156 + GrantType: "refresh_token", 157 + RefreshToken: sess.Data.RefreshToken, 158 + } 159 + 160 + resp, err := sess.postToAuthServer(ctx, sess.Data.AuthServerTokenEndpoint, body) 161 + if err != nil { 162 + return "", fmt.Errorf("token refresh failed: %w", err) 163 + } 164 + 150 165 defer resp.Body.Close() 151 166 if resp.StatusCode != http.StatusOK { 152 167 reason := parseAuthErrorReason(resp, "token-refresh") ··· 170 185 } 171 186 172 187 return sess.Data.AccessToken, nil 188 + } 189 + 190 + // If supported by the AS, use the revocation endpoint to revoke both the access token and the refresh token. 191 + // This method always succeeds - any errors during revocation are logged but not returned. 192 + func (sess *ClientSession) RevokeSession(ctx context.Context) error { 193 + sess.lk.Lock() 194 + defer sess.lk.Unlock() 195 + 196 + if sess.Data.AuthServerRevocationEndpoint == "" { 197 + return fmt.Errorf("AS does not support token revocation") 198 + } 199 + 200 + resp, err1 := sess.postToAuthServer(ctx, sess.Data.AuthServerRevocationEndpoint, RevocationRequest{ 201 + ClientID: sess.Config.ClientID, 202 + Token: sess.Data.AccessToken, 203 + TokenTypeHint: "access_token", 204 + }) 205 + if err1 != nil { 206 + err1 = fmt.Errorf("failed revoking access token: %w", err1) 207 + } else { 208 + if resp.StatusCode != http.StatusOK { 209 + err1 = fmt.Errorf("bad HTTP status while revoking access token (%d)", resp.StatusCode) 210 + } 211 + resp.Body.Close() 212 + } 213 + 214 + resp, err2 := sess.postToAuthServer(ctx, sess.Data.AuthServerRevocationEndpoint, RevocationRequest{ 215 + ClientID: sess.Config.ClientID, 216 + Token: sess.Data.RefreshToken, 217 + TokenTypeHint: "refresh_token", 218 + }) 219 + if err2 != nil { 220 + err2 = fmt.Errorf("failed revoking refresh token: %w", err1) 221 + } else { 222 + if resp.StatusCode != 200 { 223 + err2 = fmt.Errorf("bad HTTP status while revoking refresh token (%d)", resp.StatusCode) 224 + } 225 + resp.Body.Close() 226 + } 227 + 228 + return errors.Join(err1, err2) // returns nil if both errors are nil 173 229 } 174 230 175 231 // Constructs and signs a DPoP JWT to include in request header to Host (aka Resource Server, aka PDS). These tokens are different from those used with Auth Server token endpoints (even if the PDS is filling both roles)
+26
atproto/auth/oauth/types.go
··· 197 197 198 198 // must be true 199 199 ClientIDMetadataDocumentSupported bool `json:"client_id_metadata_document_supported"` 200 + 201 + // optional, used to explicitly revoke access/refresh tokens on logout, if present 202 + RevocationEndpoint string `json:"revocation_endpoint,omitempty"` 200 203 } 201 204 202 205 func (m *AuthServerMetadata) Validate(serverURL string) error { ··· 339 342 // Full token endpoint URL 340 343 AuthServerTokenEndpoint string `json:"authserver_token_endpoint"` 341 344 345 + // Full revocation endpoint, if it exists 346 + AuthServerRevocationEndpoint string `json:"authserver_revocation_endpoint,omitempty"` 347 + 342 348 // The secret token/nonce which a code challenge was generated from 343 349 PKCEVerifier string `json:"pkce_verifier"` 344 350 ··· 406 412 407 413 // Refresh token, for doing additional token requests to the auth server. 408 414 RefreshToken string `json:"refresh_token"` 415 + } 416 + 417 + // The fields which are included in a token revocation request. These HTTP POST bodies are form-encoded, so use URL encoding syntax, not JSON. 418 + // 419 + // Per https://datatracker.ietf.org/doc/html/rfc7009#section-2.1 420 + type RevocationRequest struct { 421 + // Client ID, aka client metadata URL 422 + ClientID string `url:"client_id"` 423 + 424 + // The token to revoke 425 + Token string `url:"token"` 426 + 427 + // Either "access_token" or "refresh_token" 428 + TokenTypeHint string `url:"token_type_hint"` 429 + 430 + // For confidential clients, must be "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" 431 + ClientAssertionType *string `url:"client_assertion_type"` 432 + 433 + // For confidential clients, the signed client assertion JWT 434 + ClientAssertion *string `url:"client_assertion"` 409 435 } 410 436 411 437 // Returned by [ClientApp.ProcessCallback] if the AS signals an error in the redirect URL parameters, per rfc6749 section 4.1.2.1