···288288289289func (s *Server) OAuthLogout(w http.ResponseWriter, r *http.Request) {
290290291291- // delete session from auth store
291291+ // revoke tokens and delete session from auth store
292292 did, sessionID := s.currentSessionDID(r)
293293 if did != nil {
294294- if err := s.OAuth.Store.DeleteSession(r.Context(), *did, sessionID); err != nil {
294294+ if err := s.OAuth.Logout(r.Context(), *did, sessionID); err != nil {
295295 slog.Error("failed to delete session", "did", did, "err", err)
296296 }
297297 }
+44-19
atproto/auth/oauth/oauth.go
···412412 }
413413414414 parInfo := AuthRequestData{
415415- State: state,
416416- AuthServerURL: authMeta.Issuer,
417417- Scopes: scopes,
418418- PKCEVerifier: pkceVerifier,
419419- RequestURI: parResp.RequestURI,
420420- AuthServerTokenEndpoint: authMeta.TokenEndpoint,
421421- DPoPAuthServerNonce: dpopServerNonce,
422422- DPoPPrivateKeyMultibase: dpopPrivKey.Multibase(),
415415+ State: state,
416416+ AuthServerURL: authMeta.Issuer,
417417+ Scopes: scopes,
418418+ PKCEVerifier: pkceVerifier,
419419+ RequestURI: parResp.RequestURI,
420420+ AuthServerTokenEndpoint: authMeta.TokenEndpoint,
421421+ AuthServerRevocationEndpoint: authMeta.RevocationEndpoint,
422422+ DPoPAuthServerNonce: dpopServerNonce,
423423+ DPoPPrivateKeyMultibase: dpopPrivKey.Multibase(),
423424 }
424425425426 return &parInfo, nil
···637638 }
638639639640 sessData := ClientSessionData{
640640- AccountDID: accountDID,
641641- SessionID: info.State,
642642- HostURL: hostURL,
643643- AuthServerURL: info.AuthServerURL,
644644- AuthServerTokenEndpoint: info.AuthServerTokenEndpoint,
645645- Scopes: strings.Split(tokenResp.Scope, " "),
646646- AccessToken: tokenResp.AccessToken,
647647- RefreshToken: tokenResp.RefreshToken,
648648- DPoPAuthServerNonce: info.DPoPAuthServerNonce,
649649- DPoPHostNonce: info.DPoPAuthServerNonce, // bootstrap host nonce from authserver
650650- DPoPPrivateKeyMultibase: info.DPoPPrivateKeyMultibase,
641641+ AccountDID: accountDID,
642642+ SessionID: info.State,
643643+ HostURL: hostURL,
644644+ AuthServerURL: info.AuthServerURL,
645645+ AuthServerTokenEndpoint: info.AuthServerTokenEndpoint,
646646+ AuthServerRevocationEndpoint: info.AuthServerRevocationEndpoint,
647647+ Scopes: strings.Split(tokenResp.Scope, " "),
648648+ AccessToken: tokenResp.AccessToken,
649649+ RefreshToken: tokenResp.RefreshToken,
650650+ DPoPAuthServerNonce: info.DPoPAuthServerNonce,
651651+ DPoPHostNonce: info.DPoPAuthServerNonce, // bootstrap host nonce from authserver
652652+ DPoPPrivateKeyMultibase: info.DPoPPrivateKeyMultibase,
651653 }
652654 if err := app.Store.SaveSession(ctx, sessData); err != nil {
653655 return nil, err
···658660 }
659661 return &sessData, nil
660662}
663663+664664+// High-level helper to delete a session, including revoking access/refresh tokens if supported by the AS
665665+func (app *ClientApp) Logout(ctx context.Context, did syntax.DID, sessionID string) error {
666666+ sess, err := app.ResumeSession(ctx, did, sessionID)
667667+ // TODO: Should this be idempotent? i.e. logging out of a session that does not exist does nothing and succeeds?
668668+ if err != nil {
669669+ return err
670670+ }
671671+672672+ // Tell the AS to revoke the tokens
673673+ err = sess.RevokeSession(ctx)
674674+ if err != nil {
675675+ return err
676676+ }
677677+678678+ // Delete from our own session store
679679+ err = app.Store.DeleteSession(ctx, did, sessionID)
680680+ if err != nil {
681681+ return err
682682+ }
683683+684684+ return nil
685685+}
+109
atproto/auth/oauth/session.go
···3939 // Full token endpoint
4040 AuthServerTokenEndpoint string `json:"authserver_token_endpoint"`
41414242+ // Full revocation endpoint, if it exists
4343+ AuthServerRevocationEndpoint string `json:"authserver_revocation_endpoint,omitempty"`
4444+4245 // The set of scopes approved for this session (returned in the initial token request)
4346 Scopes []string `json:"scopes"`
4447···7780 lk sync.RWMutex
7881}
79828383+// Helper method to handle DPoP retries and client assertions (if the client is confidential)
8484+// body object will be url-encoded (can be InitialTokenRequest, RefreshTokenRequest, RevocationRequest)
8585+// expects sess.lk to be held by caller
8686+// on success, caller is responsible for closing the response body
8787+func (sess *ClientSession) postToAuthServer(ctx context.Context, url string, body interface{}) (*http.Response, error) {
8888+ vals, err := query.Values(body)
8989+ if err != nil {
9090+ return nil, err
9191+ }
9292+ if sess.Config.IsConfidential() {
9393+ clientAssertion, err := sess.Config.NewClientAssertion(sess.Data.AuthServerURL)
9494+ if err != nil {
9595+ return nil, err
9696+ }
9797+ vals.Set("client_assertion_type", ClientAssertionJWTBearer)
9898+ vals.Set("client_assertion", clientAssertion)
9999+ }
100100+ bodyBytes := []byte(vals.Encode())
101101+102102+ var resp *http.Response
103103+ for range 2 {
104104+ dpopJWT, err := NewAuthDPoP("POST", url, sess.Data.DPoPAuthServerNonce, sess.DPoPPrivateKey)
105105+ if err != nil {
106106+ return nil, err
107107+ }
108108+109109+ req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(bodyBytes))
110110+ if err != nil {
111111+ return nil, err
112112+ }
113113+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
114114+ req.Header.Set("DPoP", dpopJWT)
115115+116116+ resp, err = sess.Client.Do(req)
117117+ if err != nil {
118118+ return nil, err
119119+ }
120120+121121+ // always check if a new DPoP nonce was provided, and proactively update session data (even if there was not an explicit error)
122122+ dpopNonceHdr := resp.Header.Get("DPoP-Nonce")
123123+ if dpopNonceHdr != "" && dpopNonceHdr != sess.Data.DPoPAuthServerNonce {
124124+ sess.Data.DPoPAuthServerNonce = dpopNonceHdr
125125+ }
126126+127127+ // check for an error condition caused by an out of date DPoP nonce
128128+ // 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
129129+ if resp.StatusCode == http.StatusBadRequest && dpopNonceHdr != "" {
130130+ // parseAuthErrorReason() always closes resp.Body
131131+ reason := parseAuthErrorReason(resp, "token-refresh")
132132+ if reason == "use_dpop_nonce" {
133133+ // already updated nonce value above; loop around and try again
134134+ continue
135135+ }
136136+ return nil, fmt.Errorf("request failed (HTTP %d): %s", resp.StatusCode, reason)
137137+ }
138138+139139+ // otherwise process response (success or other error type)
140140+ break
141141+ }
142142+143143+ return resp, nil
144144+}
145145+80146// Requests new tokens from auth server, and returns the new access token on success.
81147//
82148// Internally takes a lock on session data around the entire refresh process, including retries. Persists data using [PersistSessionCallback] if configured.
···170236 }
171237172238 return sess.Data.AccessToken, nil
239239+}
240240+241241+// TODO: writeme
242242+func (sess *ClientSession) RevokeSession(ctx context.Context) error {
243243+ if sess.Data.AuthServerRevocationEndpoint == "" {
244244+ slog.Info("AS does not advertise token revocation support, skipping")
245245+ return nil
246246+ }
247247+248248+ sess.lk.Lock()
249249+ defer sess.lk.Unlock()
250250+251251+ resp, err := sess.postToAuthServer(ctx, sess.Data.AuthServerRevocationEndpoint, RevocationRequest{
252252+ ClientID: sess.Config.ClientID,
253253+ Token: sess.Data.AccessToken,
254254+ TokenTypeHint: "access_token",
255255+ })
256256+ if err != nil {
257257+ slog.Warn("failed revoking access token", "err", err)
258258+ }
259259+ if resp != nil {
260260+ if resp.StatusCode != http.StatusOK {
261261+ slog.Warn("bad HTTP status while revoking access token", "status_code", resp.StatusCode)
262262+ }
263263+ resp.Body.Close()
264264+ }
265265+266266+ resp, err = sess.postToAuthServer(ctx, sess.Data.AuthServerRevocationEndpoint, RevocationRequest{
267267+ ClientID: sess.Config.ClientID,
268268+ Token: sess.Data.RefreshToken,
269269+ TokenTypeHint: "refresh_token",
270270+ })
271271+ if err != nil {
272272+ slog.Warn("failed revoking refresh token", "err", err)
273273+ }
274274+ if resp != nil {
275275+ if resp.StatusCode != 200 {
276276+ slog.Warn("bad HTTP status while revoking refresh token", "status_code", resp.StatusCode)
277277+ }
278278+ resp.Body.Close()
279279+ }
280280+281281+ return nil
173282}
174283175284// 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
···197197198198 // must be true
199199 ClientIDMetadataDocumentSupported bool `json:"client_id_metadata_document_supported"`
200200+201201+ // optional, used to explicitly revoke access/refresh tokens on logout, if present
202202+ RevocationEndpoint string `json:"revocation_endpoint,omitempty"`
200203}
201204202205func (m *AuthServerMetadata) Validate(serverURL string) error {
···339342 // Full token endpoint URL
340343 AuthServerTokenEndpoint string `json:"authserver_token_endpoint"`
341344345345+ // Full revocation endpoint, if it exists
346346+ AuthServerRevocationEndpoint string `json:"authserver_revocation_endpoint,omitempty"`
347347+342348 // The secret token/nonce which a code challenge was generated from
343349 PKCEVerifier string `json:"pkce_verifier"`
344350···407413 // Refresh token, for doing additional token requests to the auth server.
408414 RefreshToken string `json:"refresh_token"`
409415}
416416+417417+// The fields which are included in a token revocation request. These HTTP POST bodies are form-encoded, so use URL encoding syntax, not JSON.
418418+type RevocationRequest struct {
419419+ // Client ID, aka client metadata URL
420420+ ClientID string `url:"client_id"`
421421+422422+ // The token to revoke
423423+ Token string `url:"token"`
424424+425425+ // Either "access_token" or "refresh_token"
426426+ TokenTypeHint string `url:"token_type_hint"`
427427+428428+ // For confidential clients, must be "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
429429+ ClientAssertionType *string `url:"client_assertion_type"`
430430+431431+ // For confidential clients, the signed client assertion JWT
432432+ ClientAssertion *string `url:"client_assertion"`
433433+}