feat(auth): support returning extra fields from token exchange (#700)

* feat(auth): support returning extra fields that may be returned from token generation

exchange_code_for_token and refresh_token now return a StandardTokenResponse which includes
any additionalfields which might have been sent by the vendor

BREAKING CHANGE: Return type of exchange_code_for_token and refresh_token has changed
and may require code changes.

* fix: doc links
This commit is contained in:
Guy Lichtman 2026-03-02 17:38:15 +02:00 • committed by GitHub
commit 78d959fcd4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -7,16 +7,16 @@ use std::{
use async_trait::async_trait; use async_trait::async_trait;
use oauth2::{ use oauth2::{
AsyncHttpClient, AuthType, AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, AsyncHttpClient, AuthType, AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken,
EmptyExtraTokenFields, HttpClientError, HttpRequest, HttpResponse, PkceCodeChallenge, EmptyExtraTokenFields, ExtraTokenFields, HttpClientError, HttpRequest, HttpResponse,
PkceCodeVerifier, RedirectUrl, RefreshToken, RequestTokenError, Scope, StandardTokenResponse, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, RefreshToken, RequestTokenError, Scope,
TokenResponse, TokenUrl, StandardTokenResponse, TokenResponse, TokenUrl, basic::BasicTokenType,
basic::{BasicClient, BasicTokenType},
}; };
use reqwest::{ use reqwest::{
Client as HttpClient, IntoUrl, StatusCode, Url, Client as HttpClient, IntoUrl, StatusCode, Url,
header::{AUTHORIZATION, WWW_AUTHENTICATE}, header::{AUTHORIZATION, WWW_AUTHENTICATE},
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value;
use thiserror::Error; use thiserror::Error;
use tokio::sync::{Mutex, RwLock}; use tokio::sync::{Mutex, RwLock};
use tracing::{debug, error, warn}; use tracing::{debug, error, warn};
@ -126,6 +126,32 @@ pub struct StoredAuthorizationState {
pub created_at: u64, pub created_at: u64,
} }
/// A transparent wrapper around a JSON object that captures any extra fields returned by the
/// authorization server during token exchange that are not part of the standard OAuth 2.0 token
/// response.
///
/// OAuth providers may include non-standard fields alongside the
/// standard OAuth fields. Those fields are collected here so callers
/// can inspect them without losing data.
///
/// The inner [`HashMap<String, Value>`] maps field names to their raw JSON values.
///
/// # Accessing extra fields
///
/// Extra fields are available through [`StandardTokenResponse::extra_fields()`], which returns a
/// reference to this struct. Use the inner map (`.0`) to look up individual fields by name:
///
/// ```rust,ignore
/// // Obtain the token response from the AuthorizationManager, then:
/// if let Some(value) = token_response.extra_fields().0.get("vendorSpecificField") {
/// println!("vendorSpecificField = {value}");
/// }
/// ```
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct VendorExtraTokenFields(pub HashMap<String, Value>);
impl ExtraTokenFields for VendorExtraTokenFields {}
impl StoredAuthorizationState { impl StoredAuthorizationState {
pub fn new(pkce_verifier: &PkceCodeVerifier, csrf_token: &CsrfToken) -> Self { pub fn new(pkce_verifier: &PkceCodeVerifier, csrf_token: &CsrfToken) -> Self {
Self { Self {
@ -345,7 +371,18 @@ pub struct OAuthClientConfig {
// add type aliases for oauth2 types // add type aliases for oauth2 types
type OAuthErrorResponse = oauth2::StandardErrorResponse<oauth2::basic::BasicErrorResponseType>; type OAuthErrorResponse = oauth2::StandardErrorResponse<oauth2::basic::BasicErrorResponseType>;
pub type OAuthTokenResponse = StandardTokenResponse<EmptyExtraTokenFields, BasicTokenType>;
/// The token response returned by the authorization server after a successful OAuth 2.0 flow.
///
/// This is a [`StandardTokenResponse`] parameterised with [`VendorExtraTokenFields`], which means
/// it carries both the standard OAuth fields and
/// any vendor-specific fields the server may have included in the JSON response body.
///
/// # Accessing vendor-specific fields
///
/// Call [`extra_fields()`][OAuthTokenResponse::extra_fields] to obtain a reference to the
/// [`VendorExtraTokenFields`] wrapper, then index into its inner map.
pub type OAuthTokenResponse = StandardTokenResponse<VendorExtraTokenFields, BasicTokenType>;
type OAuthTokenIntrospection = type OAuthTokenIntrospection =
oauth2::StandardTokenIntrospectionResponse<EmptyExtraTokenFields, BasicTokenType>; oauth2::StandardTokenIntrospectionResponse<EmptyExtraTokenFields, BasicTokenType>;
type OAuthRevocableToken = oauth2::StandardRevocableToken; type OAuthRevocableToken = oauth2::StandardRevocableToken;
@ -581,7 +618,7 @@ impl AuthorizationManager {
let redirect_url = RedirectUrl::new(config.redirect_uri.clone()) let redirect_url = RedirectUrl::new(config.redirect_uri.clone())
.map_err(|e| AuthError::OAuthError(format!("Invalid re URL: {}", e)))?; .map_err(|e| AuthError::OAuthError(format!("Invalid re URL: {}", e)))?;
let mut client_builder = BasicClient::new(client_id.clone()) let mut client_builder: OAuthClient = oauth2::Client::new(client_id.clone())
.set_auth_uri(auth_url) .set_auth_uri(auth_url)
.set_token_uri(token_url) .set_token_uri(token_url)
.set_redirect_uri(redirect_url); .set_redirect_uri(redirect_url);
@ -882,7 +919,7 @@ impl AuthorizationManager {
&self, &self,
code: &str, code: &str,
csrf_token: &str, csrf_token: &str,
) -> Result<StandardTokenResponse<EmptyExtraTokenFields, BasicTokenType>, AuthError> { ) -> Result<OAuthTokenResponse, AuthError> {
debug!("start exchange code for token: {:?}", code); debug!("start exchange code for token: {:?}", code);
let oauth_client = self let oauth_client = self
.oauth_client .oauth_client
@ -1017,9 +1054,7 @@ impl AuthorizationManager {
} }
/// refresh access token /// refresh access token
pub async fn refresh_token( pub async fn refresh_token(&self) -> Result<OAuthTokenResponse, AuthError> {
&self,
) -> Result<StandardTokenResponse<EmptyExtraTokenFields, BasicTokenType>, AuthError> {
let oauth_client = self let oauth_client = self
.oauth_client .oauth_client
.as_ref() .as_ref()
@ -1551,7 +1586,7 @@ impl AuthorizationSession {
&self, &self,
code: &str, code: &str,
csrf_token: &str, csrf_token: &str,
) -> Result<StandardTokenResponse<EmptyExtraTokenFields, BasicTokenType>, AuthError> { ) -> Result<OAuthTokenResponse, AuthError> {
self.auth_manager self.auth_manager
.exchange_code_for_token(code, csrf_token) .exchange_code_for_token(code, csrf_token)
.await .await
@ -1876,6 +1911,7 @@ mod tests {
AuthError, AuthorizationManager, AuthorizationMetadata, InMemoryStateStore, AuthError, AuthorizationManager, AuthorizationMetadata, InMemoryStateStore,
OAuthClientConfig, ScopeUpgradeConfig, StateStore, StoredAuthorizationState, is_https_url, OAuthClientConfig, ScopeUpgradeConfig, StateStore, StoredAuthorizationState, is_https_url,
}; };
use crate::transport::auth::VendorExtraTokenFields;
// -- url helpers -- // -- url helpers --
@ -2686,11 +2722,13 @@ mod tests {
use super::{OAuthTokenResponse, StoredCredentials}; use super::{OAuthTokenResponse, StoredCredentials};
fn make_token_response(access_token: &str, expires_in_secs: Option<u64>) -> OAuthTokenResponse { fn make_token_response(access_token: &str, expires_in_secs: Option<u64>) -> OAuthTokenResponse {
use oauth2::{AccessToken, EmptyExtraTokenFields, basic::BasicTokenType}; use oauth2::{AccessToken, basic::BasicTokenType};
let mut resp = OAuthTokenResponse::new( let mut resp = OAuthTokenResponse::new(
AccessToken::new(access_token.to_string()), AccessToken::new(access_token.to_string()),
BasicTokenType::Bearer, BasicTokenType::Bearer,
EmptyExtraTokenFields {}, VendorExtraTokenFields {
..Default::default()
},
); );
if let Some(secs) = expires_in_secs { if let Some(secs) = expires_in_secs {
resp.set_expires_in(Some(&std::time::Duration::from_secs(secs))); resp.set_expires_in(Some(&std::time::Duration::from_secs(secs)));