this repo has no description
0
fork

Configure Feed

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

oauth client: token revocation

+179 -21
+2 -2
atproto/auth/oauth/cmd/oauth-web-demo/main.go
··· 288 288 289 289 func (s *Server) OAuthLogout(w http.ResponseWriter, r *http.Request) { 290 290 291 - // delete session from auth store 291 + // revoke tokens and delete session from auth store 292 292 did, sessionID := s.currentSessionDID(r) 293 293 if did != nil { 294 - if err := s.OAuth.Store.DeleteSession(r.Context(), *did, sessionID); err != nil { 294 + if err := s.OAuth.Logout(r.Context(), *did, sessionID); err != nil { 295 295 slog.Error("failed to delete session", "did", did, "err", err) 296 296 } 297 297 }
+44 -19
atproto/auth/oauth/oauth.go
··· 412 412 } 413 413 414 414 parInfo := AuthRequestData{ 415 - State: state, 416 - AuthServerURL: authMeta.Issuer, 417 - Scopes: scopes, 418 - PKCEVerifier: pkceVerifier, 419 - RequestURI: parResp.RequestURI, 420 - AuthServerTokenEndpoint: authMeta.TokenEndpoint, 421 - DPoPAuthServerNonce: dpopServerNonce, 422 - DPoPPrivateKeyMultibase: dpopPrivKey.Multibase(), 415 + State: state, 416 + AuthServerURL: authMeta.Issuer, 417 + Scopes: scopes, 418 + PKCEVerifier: pkceVerifier, 419 + RequestURI: parResp.RequestURI, 420 + AuthServerTokenEndpoint: authMeta.TokenEndpoint, 421 + AuthServerRevocationEndpoint: authMeta.RevocationEndpoint, 422 + DPoPAuthServerNonce: dpopServerNonce, 423 + DPoPPrivateKeyMultibase: dpopPrivKey.Multibase(), 423 424 } 424 425 425 426 return &parInfo, nil ··· 637 638 } 638 639 639 640 sessData := ClientSessionData{ 640 - AccountDID: accountDID, 641 - SessionID: info.State, 642 - HostURL: hostURL, 643 - AuthServerURL: info.AuthServerURL, 644 - AuthServerTokenEndpoint: info.AuthServerTokenEndpoint, 645 - Scopes: strings.Split(tokenResp.Scope, " "), 646 - AccessToken: tokenResp.AccessToken, 647 - RefreshToken: tokenResp.RefreshToken, 648 - DPoPAuthServerNonce: info.DPoPAuthServerNonce, 649 - DPoPHostNonce: info.DPoPAuthServerNonce, // bootstrap host nonce from authserver 650 - DPoPPrivateKeyMultibase: info.DPoPPrivateKeyMultibase, 641 + AccountDID: accountDID, 642 + SessionID: info.State, 643 + HostURL: hostURL, 644 + AuthServerURL: info.AuthServerURL, 645 + AuthServerTokenEndpoint: info.AuthServerTokenEndpoint, 646 + AuthServerRevocationEndpoint: info.AuthServerRevocationEndpoint, 647 + Scopes: strings.Split(tokenResp.Scope, " "), 648 + AccessToken: tokenResp.AccessToken, 649 + RefreshToken: tokenResp.RefreshToken, 650 + DPoPAuthServerNonce: info.DPoPAuthServerNonce, 651 + DPoPHostNonce: info.DPoPAuthServerNonce, // bootstrap host nonce from authserver 652 + DPoPPrivateKeyMultibase: info.DPoPPrivateKeyMultibase, 651 653 } 652 654 if err := app.Store.SaveSession(ctx, sessData); err != nil { 653 655 return nil, err ··· 658 660 } 659 661 return &sessData, nil 660 662 } 663 + 664 + // High-level helper to delete a session, including revoking access/refresh tokens if supported by the AS 665 + func (app *ClientApp) Logout(ctx context.Context, did syntax.DID, sessionID string) error { 666 + sess, err := app.ResumeSession(ctx, did, sessionID) 667 + // TODO: Should this be idempotent? i.e. logging out of a session that does not exist does nothing and succeeds? 668 + if err != nil { 669 + return err 670 + } 671 + 672 + // Tell the AS to revoke the tokens 673 + err = sess.RevokeSession(ctx) 674 + if err != nil { 675 + return err 676 + } 677 + 678 + // Delete from our own session store 679 + err = app.Store.DeleteSession(ctx, did, sessionID) 680 + if err != nil { 681 + return err 682 + } 683 + 684 + return nil 685 + }
+109
atproto/auth/oauth/session.go
··· 39 39 // Full token endpoint 40 40 AuthServerTokenEndpoint string `json:"authserver_token_endpoint"` 41 41 42 + // Full revocation endpoint, if it exists 43 + AuthServerRevocationEndpoint string `json:"authserver_revocation_endpoint,omitempty"` 44 + 42 45 // The set of scopes approved for this session (returned in the initial token request) 43 46 Scopes []string `json:"scopes"` 44 47 ··· 77 80 lk sync.RWMutex 78 81 } 79 82 83 + // Helper method to handle DPoP retries and client assertions (if the client is confidential) 84 + // body object will be url-encoded (can be InitialTokenRequest, RefreshTokenRequest, RevocationRequest) 85 + // expects sess.lk to be held by caller 86 + // on success, caller is responsible for closing the response body 87 + func (sess *ClientSession) postToAuthServer(ctx context.Context, url string, body interface{}) (*http.Response, error) { 88 + vals, err := query.Values(body) 89 + if err != nil { 90 + return nil, err 91 + } 92 + if sess.Config.IsConfidential() { 93 + clientAssertion, err := sess.Config.NewClientAssertion(sess.Data.AuthServerURL) 94 + if err != nil { 95 + return nil, err 96 + } 97 + vals.Set("client_assertion_type", ClientAssertionJWTBearer) 98 + vals.Set("client_assertion", clientAssertion) 99 + } 100 + bodyBytes := []byte(vals.Encode()) 101 + 102 + var resp *http.Response 103 + for range 2 { 104 + dpopJWT, err := NewAuthDPoP("POST", url, sess.Data.DPoPAuthServerNonce, sess.DPoPPrivateKey) 105 + if err != nil { 106 + return nil, err 107 + } 108 + 109 + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(bodyBytes)) 110 + if err != nil { 111 + return nil, err 112 + } 113 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 114 + req.Header.Set("DPoP", dpopJWT) 115 + 116 + resp, err = sess.Client.Do(req) 117 + if err != nil { 118 + return nil, err 119 + } 120 + 121 + // always check if a new DPoP nonce was provided, and proactively update session data (even if there was not an explicit error) 122 + dpopNonceHdr := resp.Header.Get("DPoP-Nonce") 123 + if dpopNonceHdr != "" && dpopNonceHdr != sess.Data.DPoPAuthServerNonce { 124 + sess.Data.DPoPAuthServerNonce = dpopNonceHdr 125 + } 126 + 127 + // check for an error condition caused by an out of date DPoP nonce 128 + // note that the HTTP status code is 400 Bad Request on the Auth Server token endpoint, not 401 Unauthorized like it would be on Resource Server requests 129 + if resp.StatusCode == http.StatusBadRequest && dpopNonceHdr != "" { 130 + // parseAuthErrorReason() always closes resp.Body 131 + reason := parseAuthErrorReason(resp, "token-refresh") 132 + if reason == "use_dpop_nonce" { 133 + // already updated nonce value above; loop around and try again 134 + continue 135 + } 136 + return nil, fmt.Errorf("request failed (HTTP %d): %s", resp.StatusCode, reason) 137 + } 138 + 139 + // otherwise process response (success or other error type) 140 + break 141 + } 142 + 143 + return resp, nil 144 + } 145 + 80 146 // Requests new tokens from auth server, and returns the new access token on success. 81 147 // 82 148 // Internally takes a lock on session data around the entire refresh process, including retries. Persists data using [PersistSessionCallback] if configured. ··· 170 236 } 171 237 172 238 return sess.Data.AccessToken, nil 239 + } 240 + 241 + // TODO: writeme 242 + func (sess *ClientSession) RevokeSession(ctx context.Context) error { 243 + if sess.Data.AuthServerRevocationEndpoint == "" { 244 + slog.Info("AS does not advertise token revocation support, skipping") 245 + return nil 246 + } 247 + 248 + sess.lk.Lock() 249 + defer sess.lk.Unlock() 250 + 251 + resp, err := sess.postToAuthServer(ctx, sess.Data.AuthServerRevocationEndpoint, RevocationRequest{ 252 + ClientID: sess.Config.ClientID, 253 + Token: sess.Data.AccessToken, 254 + TokenTypeHint: "access_token", 255 + }) 256 + if err != nil { 257 + slog.Warn("failed revoking access token", "err", err) 258 + } 259 + if resp != nil { 260 + if resp.StatusCode != http.StatusOK { 261 + slog.Warn("bad HTTP status while revoking access token", "status_code", resp.StatusCode) 262 + } 263 + resp.Body.Close() 264 + } 265 + 266 + resp, err = sess.postToAuthServer(ctx, sess.Data.AuthServerRevocationEndpoint, RevocationRequest{ 267 + ClientID: sess.Config.ClientID, 268 + Token: sess.Data.RefreshToken, 269 + TokenTypeHint: "refresh_token", 270 + }) 271 + if err != nil { 272 + slog.Warn("failed revoking refresh token", "err", err) 273 + } 274 + if resp != nil { 275 + if resp.StatusCode != 200 { 276 + slog.Warn("bad HTTP status while revoking refresh token", "status_code", resp.StatusCode) 277 + } 278 + resp.Body.Close() 279 + } 280 + 281 + return nil 173 282 } 174 283 175 284 // 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)
+24
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 ··· 407 413 // Refresh token, for doing additional token requests to the auth server. 408 414 RefreshToken string `json:"refresh_token"` 409 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 + type RevocationRequest struct { 419 + // Client ID, aka client metadata URL 420 + ClientID string `url:"client_id"` 421 + 422 + // The token to revoke 423 + Token string `url:"token"` 424 + 425 + // Either "access_token" or "refresh_token" 426 + TokenTypeHint string `url:"token_type_hint"` 427 + 428 + // For confidential clients, must be "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" 429 + ClientAssertionType *string `url:"client_assertion_type"` 430 + 431 + // For confidential clients, the signed client assertion JWT 432 + ClientAssertion *string `url:"client_assertion"` 433 + }