feat: Add support for OAuth client credentials grant

This commit is contained in:
Ginger committed 2026-11-05 10:35:17 -05:00
1 parent 8c720e5427
commit 4922e5da01
9 files changed
+258 -12

No files matched your search

+7
View File
@@ -125,6 +125,12 @@ impl ClientMetadata {
}
}
if self.grant_types.contains(&GrantType::ClientCredentials) {
return Err(
"Dynamically registered clients may not use the client credentials grant."
);
}
Ok(())
}
}
@@ -144,6 +150,7 @@ pub enum GrantType {
#[serde(rename = "urn:ietf:params:oauth:grant-type:device_code")]
DeviceCode,
RefreshToken,
ClientCredentials,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Deserialize, Serialize)]
+6 -1
View File
@@ -225,6 +225,10 @@ pub enum TokenRequestType {
RefreshToken {
refresh_token: String,
},
ClientCredentials {
client_secret: String,
scope: RawScopes,
},
}
impl TokenRequestType {
@@ -234,6 +238,7 @@ impl TokenRequestType {
| Self::AuthorizationCode { .. } => GrantType::AuthorizationCode,
| Self::DeviceCode { .. } => GrantType::DeviceCode,
| Self::RefreshToken { .. } => GrantType::RefreshToken,
| Self::ClientCredentials { .. } => GrantType::ClientCredentials,
}
}
}
@@ -243,7 +248,7 @@ pub struct TokenResponse {
pub access_token: String,
pub token_type: TokenType,
pub expires_in: u64,
pub refresh_token: String,
pub refresh_token: Option<String>,
pub scope: String,
}
+113 -9
View File
@@ -23,8 +23,9 @@ use crate::{
client_metadata::{ApplicationType, ClientMetadata, GrantType, ResponseType},
grant::{
AuthorizationCodeQuery, AuthorizationCodeResponse, CodeChallengeMethod,
DeviceCodeRequest, DeviceCodeResponse, ErrorCode, OAuthError, RequestedScopes,
ResponseMode, TokenRequest, TokenRequestType, TokenResponse, TokenType,
DeviceCodeRequest, DeviceCodeResponse, ErrorCode, OAuthError, RawScopes,
RequestedScopes, ResponseMode, TokenRequest, TokenRequestType, TokenResponse,
TokenType,
},
},
users::{self, DeviceToken},
@@ -39,6 +40,7 @@ pub struct Service {
tickets: SyncMutex<HashMap<String, HashMap<OAuthTicket, SystemTime>>>,
pending_auth_code_grants: SyncMutex<LruCache<String, PendingAuthCodeGrant>>,
pending_device_code_grants: SyncMutex<LruCache<String, PendingDeviceCodeGrant>>,
client_credential_sessions: tokio::sync::Mutex<HashMap<String, ClientCredentialsSession>>,
}
struct Data {
@@ -78,6 +80,12 @@ struct RefreshTokenInfo {
device_id: OwnedDeviceId,
}
pub struct ClientCredentialsSession {
pub client_id: String,
pub scopes: BTreeSet<OAuthClientScope>,
created_at: SystemTime,
}
struct PendingAuthCodeGrant {
authorizing_user: OwnedUserId,
requested_scopes: RequestedScopes,
@@ -172,6 +180,7 @@ impl crate::Service for Service {
tickets: SyncMutex::default(),
pending_auth_code_grants: SyncMutex::new(LruCache::new(Self::MAX_PENDING_GRANTS)),
pending_device_code_grants: SyncMutex::new(LruCache::new(Self::MAX_PENDING_GRANTS)),
client_credential_sessions: tokio::sync::Mutex::default(),
}))
}
@@ -448,12 +457,25 @@ impl Service {
pub async fn issue_token(&self, request: TokenRequest) -> Result<TokenResponse, OAuthError> {
let TokenRequest { client_id, request } = request;
let Some(client_metadata) = self.get_client_metadata(&client_id).await else {
return Err(OAuthError::new_static(ErrorCode::InvalidClient, "Invalid client ID"));
};
if !matches!(request, TokenRequestType::ClientCredentials { .. }) {
// The client credentials grant can only be used by statically
// registered clients, and the other grant types can
// only be used by dynamically registered clients, so we
// don't look up the dynamic client metadata if the token request is
// for the client credentials grant.
if !client_metadata.grant_types.contains(&request.grant_type()) {
return Err(OAuthError::unauthorized_client("Client cannot request this grant type"));
let Some(client_metadata) = self.get_client_metadata(&client_id).await else {
return Err(OAuthError::new_static(
ErrorCode::InvalidClient,
"Invalid client ID",
));
};
if !client_metadata.grant_types.contains(&request.grant_type()) {
return Err(OAuthError::unauthorized_client(
"Client cannot request this grant type",
));
}
}
match request {
@@ -525,6 +547,9 @@ impl Service {
},
| TokenRequestType::RefreshToken { refresh_token } =>
self.refresh_session(client_id, refresh_token).await,
| TokenRequestType::ClientCredentials { client_secret, scope: scopes } =>
self.create_client_credentials_token(&client_id, &client_secret, scopes)
.await,
}
}
@@ -668,7 +693,7 @@ impl Service {
token_type: TokenType::Bearer,
expires_in: Self::ACCESS_TOKEN_MAX_AGE.as_secs(),
scope: response_scope,
refresh_token,
refresh_token: Some(refresh_token),
})
}
@@ -738,10 +763,89 @@ impl Service {
token_type: TokenType::Bearer,
expires_in: Self::ACCESS_TOKEN_MAX_AGE.as_secs(),
scope,
refresh_token: new_refresh_token,
refresh_token: Some(new_refresh_token),
})
}
async fn create_client_credentials_token(
&self,
client_id: &str,
client_secret: &str,
scopes: RawScopes,
) -> Result<TokenResponse, OAuthError> {
let now = SystemTime::now();
let Some(client) = self.services.config.oauth.clients.get(client_id) else {
return Err(OAuthError::new_static(ErrorCode::InvalidClient, "Unknown client ID"));
};
let RequestedScopes { device_id, scopes } = scopes
.to_scopes()
.map_err(|err| OAuthError::new(ErrorCode::InvalidGrant, err))?;
if device_id.is_some() {
return Err(OAuthError::new_static(
ErrorCode::InvalidGrant,
"Static clients may not request a device ID",
));
}
if client_secret != client.client_secret {
return Err(OAuthError::new_static(
ErrorCode::InvalidClient,
"Client secret does not match",
));
}
if !scopes.is_subset(&client.scopes) {
return Err(OAuthError::new_static(
ErrorCode::InvalidScope,
"This client may not request those scopes",
));
}
let mut sessions = self.client_credential_sessions.lock().await;
// Clear expired sessions while we're at it
sessions.retain(|_, session| {
now.duration_since(session.created_at)
.is_ok_and(|age| age <= Self::ACCESS_TOKEN_MAX_AGE)
});
let access_token = Self::generate_token();
let scope = scopes.iter().join(" ");
sessions.insert(access_token.clone(), ClientCredentialsSession {
client_id: client_id.to_owned(),
scopes,
created_at: now,
});
Ok(TokenResponse {
access_token,
token_type: TokenType::Bearer,
expires_in: Self::ACCESS_TOKEN_MAX_AGE.as_secs(),
refresh_token: None,
scope,
})
}
pub async fn get_client_credentials_session(
&self,
token: &str,
) -> Option<tokio::sync::MappedMutexGuard<'_, ClientCredentialsSession>> {
let sessions = self.client_credential_sessions.lock().await;
tokio::sync::MutexGuard::try_map(sessions, |sessions| {
sessions.get_mut(token).filter(|session| {
SystemTime::now()
.duration_since(session.created_at)
.is_ok_and(|age| age <= Self::ACCESS_TOKEN_MAX_AGE)
})
})
.ok()
}
pub async fn remove_session(&self, user_id: &UserId, device_id: &DeviceId) {
let session_info = self.get_session_info_for_device(user_id, device_id).await;