You've already forked authentication-service
mirror of
https://github.com/matrix-org/matrix-authentication-service.git
synced 2025-08-09 04:22:45 +03:00
806 lines
27 KiB
Rust
806 lines
27 KiB
Rust
// Copyright 2021-2023 The Matrix.org Foundation C.I.C.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
use axum::{extract::State, response::IntoResponse, Json};
|
|
use chrono::{DateTime, Duration, Utc};
|
|
use headers::{CacheControl, HeaderMap, HeaderMapExt, Pragma};
|
|
use hyper::StatusCode;
|
|
use mas_axum_utils::{
|
|
client_authorization::{ClientAuthorization, CredentialsVerificationError},
|
|
http_client_factory::HttpClientFactory,
|
|
};
|
|
use mas_data_model::{AuthorizationGrantStage, Client, Device};
|
|
use mas_keystore::{Encrypter, Keystore};
|
|
use mas_router::UrlBuilder;
|
|
use mas_storage::{
|
|
job::{JobRepositoryExt, ProvisionDeviceJob},
|
|
oauth2::{
|
|
OAuth2AccessTokenRepository, OAuth2AuthorizationGrantRepository,
|
|
OAuth2RefreshTokenRepository, OAuth2SessionRepository,
|
|
},
|
|
user::BrowserSessionRepository,
|
|
BoxClock, BoxRepository, BoxRng, Clock, RepositoryAccess,
|
|
};
|
|
use oauth2_types::{
|
|
errors::{ClientError, ClientErrorCode},
|
|
pkce::CodeChallengeError,
|
|
requests::{
|
|
AccessTokenRequest, AccessTokenResponse, AuthorizationCodeGrant, RefreshTokenGrant,
|
|
},
|
|
scope,
|
|
};
|
|
use serde::Serialize;
|
|
use serde_with::{serde_as, skip_serializing_none};
|
|
use thiserror::Error;
|
|
use tracing::debug;
|
|
use url::Url;
|
|
|
|
use super::{generate_id_token, generate_token_pair};
|
|
use crate::{impl_from_error_for_route, site_config::SiteConfig};
|
|
|
|
#[serde_as]
|
|
#[skip_serializing_none]
|
|
#[derive(Serialize, Debug)]
|
|
struct CustomClaims {
|
|
#[serde(rename = "iss")]
|
|
issuer: Url,
|
|
#[serde(rename = "sub")]
|
|
subject: String,
|
|
#[serde(rename = "aud")]
|
|
audiences: Vec<String>,
|
|
nonce: Option<String>,
|
|
#[serde_as(as = "Option<serde_with::TimestampSeconds>")]
|
|
auth_time: Option<DateTime<Utc>>,
|
|
at_hash: String,
|
|
c_hash: String,
|
|
}
|
|
|
|
#[derive(Debug, Error)]
|
|
pub(crate) enum RouteError {
|
|
#[error(transparent)]
|
|
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
|
|
|
|
#[error("bad request")]
|
|
BadRequest,
|
|
|
|
#[error("pkce verification failed")]
|
|
PkceVerification(#[from] CodeChallengeError),
|
|
|
|
#[error("client not found")]
|
|
ClientNotFound,
|
|
|
|
#[error("client not allowed")]
|
|
ClientNotAllowed,
|
|
|
|
#[error("could not verify client credentials")]
|
|
ClientCredentialsVerification(#[from] CredentialsVerificationError),
|
|
|
|
#[error("grant not found")]
|
|
GrantNotFound,
|
|
|
|
#[error("invalid grant")]
|
|
InvalidGrant,
|
|
|
|
#[error("unsupported grant type")]
|
|
UnsupportedGrantType,
|
|
|
|
#[error("unauthorized client")]
|
|
UnauthorizedClient,
|
|
|
|
#[error("failed to load browser session")]
|
|
NoSuchBrowserSession,
|
|
|
|
#[error("failed to load oauth session")]
|
|
NoSuchOAuthSession,
|
|
}
|
|
|
|
impl IntoResponse for RouteError {
|
|
fn into_response(self) -> axum::response::Response {
|
|
sentry::capture_error(&self);
|
|
match self {
|
|
Self::Internal(_) | Self::NoSuchBrowserSession | Self::NoSuchOAuthSession => (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(ClientError::from(ClientErrorCode::ServerError)),
|
|
),
|
|
Self::BadRequest => (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(ClientError::from(ClientErrorCode::InvalidRequest)),
|
|
),
|
|
Self::PkceVerification(err) => (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(
|
|
ClientError::from(ClientErrorCode::InvalidGrant)
|
|
.with_description(format!("PKCE verification failed: {err}")),
|
|
),
|
|
),
|
|
Self::ClientNotFound | Self::ClientCredentialsVerification(_) => (
|
|
StatusCode::UNAUTHORIZED,
|
|
Json(ClientError::from(ClientErrorCode::InvalidClient)),
|
|
),
|
|
Self::ClientNotAllowed | Self::UnauthorizedClient => (
|
|
StatusCode::UNAUTHORIZED,
|
|
Json(ClientError::from(ClientErrorCode::UnauthorizedClient)),
|
|
),
|
|
Self::InvalidGrant | Self::GrantNotFound => (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(ClientError::from(ClientErrorCode::InvalidGrant)),
|
|
),
|
|
Self::UnsupportedGrantType => (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(ClientError::from(ClientErrorCode::UnsupportedGrantType)),
|
|
),
|
|
}
|
|
.into_response()
|
|
}
|
|
}
|
|
|
|
impl_from_error_for_route!(mas_storage::RepositoryError);
|
|
impl_from_error_for_route!(super::IdTokenSignatureError);
|
|
|
|
#[tracing::instrument(
|
|
name = "handlers.oauth2.token.post",
|
|
fields(client.id = client_authorization.client_id()),
|
|
skip_all,
|
|
err,
|
|
)]
|
|
pub(crate) async fn post(
|
|
mut rng: BoxRng,
|
|
clock: BoxClock,
|
|
State(http_client_factory): State<HttpClientFactory>,
|
|
State(key_store): State<Keystore>,
|
|
State(url_builder): State<UrlBuilder>,
|
|
mut repo: BoxRepository,
|
|
State(site_config): State<SiteConfig>,
|
|
State(encrypter): State<Encrypter>,
|
|
client_authorization: ClientAuthorization<AccessTokenRequest>,
|
|
) -> Result<impl IntoResponse, RouteError> {
|
|
let client = client_authorization
|
|
.credentials
|
|
.fetch(&mut repo)
|
|
.await?
|
|
.ok_or(RouteError::ClientNotFound)?;
|
|
|
|
let method = client
|
|
.token_endpoint_auth_method
|
|
.as_ref()
|
|
.ok_or(RouteError::ClientNotAllowed)?;
|
|
|
|
client_authorization
|
|
.credentials
|
|
.verify(&http_client_factory, &encrypter, method, &client)
|
|
.await?;
|
|
|
|
let form = client_authorization.form.ok_or(RouteError::BadRequest)?;
|
|
|
|
let (reply, repo) = match form {
|
|
AccessTokenRequest::AuthorizationCode(grant) => {
|
|
authorization_code_grant(
|
|
&mut rng,
|
|
&clock,
|
|
&grant,
|
|
&client,
|
|
&key_store,
|
|
&url_builder,
|
|
&site_config,
|
|
repo,
|
|
)
|
|
.await?
|
|
}
|
|
AccessTokenRequest::RefreshToken(grant) => {
|
|
refresh_token_grant(&mut rng, &clock, &grant, &client, &site_config, repo).await?
|
|
}
|
|
_ => {
|
|
return Err(RouteError::UnsupportedGrantType);
|
|
}
|
|
};
|
|
|
|
repo.save().await?;
|
|
|
|
let mut headers = HeaderMap::new();
|
|
headers.typed_insert(CacheControl::new().with_no_store());
|
|
headers.typed_insert(Pragma::no_cache());
|
|
|
|
Ok((headers, Json(reply)))
|
|
}
|
|
|
|
#[allow(clippy::too_many_lines)] // TODO: refactor some parts out
|
|
async fn authorization_code_grant(
|
|
mut rng: &mut BoxRng,
|
|
clock: &impl Clock,
|
|
grant: &AuthorizationCodeGrant,
|
|
client: &Client,
|
|
key_store: &Keystore,
|
|
url_builder: &UrlBuilder,
|
|
site_config: &SiteConfig,
|
|
mut repo: BoxRepository,
|
|
) -> Result<(AccessTokenResponse, BoxRepository), RouteError> {
|
|
let authz_grant = repo
|
|
.oauth2_authorization_grant()
|
|
.find_by_code(&grant.code)
|
|
.await?
|
|
.ok_or(RouteError::GrantNotFound)?;
|
|
|
|
let now = clock.now();
|
|
|
|
let session_id = match authz_grant.stage {
|
|
AuthorizationGrantStage::Cancelled { cancelled_at } => {
|
|
debug!(%cancelled_at, "Authorization grant was cancelled");
|
|
return Err(RouteError::InvalidGrant);
|
|
}
|
|
AuthorizationGrantStage::Exchanged {
|
|
exchanged_at,
|
|
fulfilled_at,
|
|
session_id,
|
|
} => {
|
|
debug!(%exchanged_at, %fulfilled_at, "Authorization code was already exchanged");
|
|
|
|
// Ending the session if the token was already exchanged more than 20s ago
|
|
if now - exchanged_at > Duration::seconds(20) {
|
|
debug!("Ending potentially compromised session");
|
|
let session = repo
|
|
.oauth2_session()
|
|
.lookup(session_id)
|
|
.await?
|
|
.ok_or(RouteError::NoSuchOAuthSession)?;
|
|
repo.oauth2_session().finish(clock, session).await?;
|
|
repo.save().await?;
|
|
}
|
|
|
|
return Err(RouteError::InvalidGrant);
|
|
}
|
|
AuthorizationGrantStage::Pending => {
|
|
debug!("Authorization grant has not been fulfilled yet");
|
|
return Err(RouteError::InvalidGrant);
|
|
}
|
|
AuthorizationGrantStage::Fulfilled {
|
|
session_id,
|
|
fulfilled_at,
|
|
} => {
|
|
if now - fulfilled_at > Duration::minutes(10) {
|
|
debug!("Code exchange took more than 10 minutes");
|
|
return Err(RouteError::InvalidGrant);
|
|
}
|
|
|
|
session_id
|
|
}
|
|
};
|
|
|
|
let session = repo
|
|
.oauth2_session()
|
|
.lookup(session_id)
|
|
.await?
|
|
.ok_or(RouteError::NoSuchOAuthSession)?;
|
|
|
|
// This should never happen, since we looked up in the database using the code
|
|
let code = authz_grant.code.as_ref().ok_or(RouteError::InvalidGrant)?;
|
|
|
|
if client.id != session.client_id {
|
|
return Err(RouteError::UnauthorizedClient);
|
|
}
|
|
|
|
match (code.pkce.as_ref(), grant.code_verifier.as_ref()) {
|
|
(None, None) => {}
|
|
// We have a challenge but no verifier (or vice-versa)? Bad request.
|
|
(Some(_), None) | (None, Some(_)) => return Err(RouteError::BadRequest),
|
|
// If we have both, we need to check the code validity
|
|
(Some(pkce), Some(verifier)) => {
|
|
pkce.verify(verifier)?;
|
|
}
|
|
};
|
|
|
|
let Some(user_session_id) = session.user_session_id else {
|
|
tracing::warn!("No user session associated with this OAuth2 session");
|
|
return Err(RouteError::InvalidGrant);
|
|
};
|
|
|
|
let browser_session = repo
|
|
.browser_session()
|
|
.lookup(user_session_id)
|
|
.await?
|
|
.ok_or(RouteError::NoSuchBrowserSession)?;
|
|
|
|
let last_authentication = repo
|
|
.browser_session()
|
|
.get_last_authentication(&browser_session)
|
|
.await?;
|
|
|
|
let ttl = site_config.access_token_ttl;
|
|
let (access_token, refresh_token) =
|
|
generate_token_pair(&mut rng, clock, &mut repo, &session, ttl).await?;
|
|
|
|
let id_token = if session.scope.contains(&scope::OPENID) {
|
|
Some(generate_id_token(
|
|
&mut rng,
|
|
clock,
|
|
url_builder,
|
|
key_store,
|
|
client,
|
|
&authz_grant,
|
|
&browser_session,
|
|
Some(&access_token),
|
|
last_authentication.as_ref(),
|
|
)?)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let mut params = AccessTokenResponse::new(access_token.access_token)
|
|
.with_expires_in(ttl)
|
|
.with_refresh_token(refresh_token.refresh_token)
|
|
.with_scope(session.scope.clone());
|
|
|
|
if let Some(id_token) = id_token {
|
|
params = params.with_id_token(id_token);
|
|
}
|
|
|
|
// Look for device to provision
|
|
for scope in &*session.scope {
|
|
if let Some(device) = Device::from_scope_token(scope) {
|
|
// Note that we're not waiting for the job to finish, we just schedule it. We
|
|
// might get in a situation where the provisioning job is not finished when the
|
|
// client does its first request to the Homeserver. This is fine for now, since
|
|
// Synapse still provision devices on-the-fly if it doesn't find them in the
|
|
// database.
|
|
repo.job()
|
|
.schedule_job(ProvisionDeviceJob::new(&browser_session.user, &device))
|
|
.await?;
|
|
}
|
|
}
|
|
|
|
repo.oauth2_authorization_grant()
|
|
.exchange(clock, authz_grant)
|
|
.await?;
|
|
|
|
Ok((params, repo))
|
|
}
|
|
|
|
async fn refresh_token_grant(
|
|
rng: &mut BoxRng,
|
|
clock: &impl Clock,
|
|
grant: &RefreshTokenGrant,
|
|
client: &Client,
|
|
site_config: &SiteConfig,
|
|
mut repo: BoxRepository,
|
|
) -> Result<(AccessTokenResponse, BoxRepository), RouteError> {
|
|
let refresh_token = repo
|
|
.oauth2_refresh_token()
|
|
.find_by_token(&grant.refresh_token)
|
|
.await?
|
|
.ok_or(RouteError::InvalidGrant)?;
|
|
|
|
let session = repo
|
|
.oauth2_session()
|
|
.lookup(refresh_token.session_id)
|
|
.await?
|
|
.ok_or(RouteError::NoSuchOAuthSession)?;
|
|
|
|
if !refresh_token.is_valid() || !session.is_valid() {
|
|
return Err(RouteError::InvalidGrant);
|
|
}
|
|
|
|
if client.id != session.client_id {
|
|
// As per https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
|
|
return Err(RouteError::InvalidGrant);
|
|
}
|
|
|
|
let ttl = site_config.access_token_ttl;
|
|
let (new_access_token, new_refresh_token) =
|
|
generate_token_pair(rng, clock, &mut repo, &session, ttl).await?;
|
|
|
|
let refresh_token = repo
|
|
.oauth2_refresh_token()
|
|
.consume(clock, refresh_token)
|
|
.await?;
|
|
|
|
if let Some(access_token_id) = refresh_token.access_token_id {
|
|
let access_token = repo.oauth2_access_token().lookup(access_token_id).await?;
|
|
if let Some(access_token) = access_token {
|
|
repo.oauth2_access_token()
|
|
.revoke(clock, access_token)
|
|
.await?;
|
|
}
|
|
}
|
|
|
|
let params = AccessTokenResponse::new(new_access_token.access_token)
|
|
.with_expires_in(ttl)
|
|
.with_refresh_token(new_refresh_token.refresh_token)
|
|
.with_scope(session.scope);
|
|
|
|
Ok((params, repo))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use hyper::Request;
|
|
use mas_data_model::{AccessToken, AuthorizationCode, RefreshToken};
|
|
use mas_router::SimpleRoute;
|
|
use oauth2_types::{
|
|
registration::ClientRegistrationResponse,
|
|
requests::ResponseMode,
|
|
scope::{Scope, OPENID},
|
|
};
|
|
use sqlx::PgPool;
|
|
|
|
use super::*;
|
|
use crate::test_utils::{init_tracing, RequestBuilderExt, ResponseExt, TestState};
|
|
|
|
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
|
|
async fn test_auth_code_grant(pool: PgPool) {
|
|
init_tracing();
|
|
let state = TestState::from_pool(pool).await.unwrap();
|
|
|
|
// Provision a client
|
|
let request =
|
|
Request::post(mas_router::OAuth2RegistrationEndpoint::PATH).json(serde_json::json!({
|
|
"client_uri": "https://example.com/",
|
|
"redirect_uris": ["https://example.com/callback"],
|
|
"contacts": ["contact@example.com"],
|
|
"token_endpoint_auth_method": "none",
|
|
"response_types": ["code"],
|
|
"grant_types": ["authorization_code"],
|
|
}));
|
|
|
|
let response = state.request(request).await;
|
|
response.assert_status(StatusCode::CREATED);
|
|
|
|
let ClientRegistrationResponse { client_id, .. } = response.json();
|
|
|
|
// Let's provision a user and create a session for them. This part is hard to
|
|
// test with just HTTP requests, so we'll use the repository directly.
|
|
let mut repo = state.repository().await.unwrap();
|
|
|
|
let user = repo
|
|
.user()
|
|
.add(&mut state.rng(), &state.clock, "alice".to_owned())
|
|
.await
|
|
.unwrap();
|
|
|
|
let browser_session = repo
|
|
.browser_session()
|
|
.add(&mut state.rng(), &state.clock, &user, None)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Lookup the client in the database.
|
|
let client = repo
|
|
.oauth2_client()
|
|
.find_by_client_id(&client_id)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
|
|
// Start a grant
|
|
let code = "thisisaverysecurecode";
|
|
let grant = repo
|
|
.oauth2_authorization_grant()
|
|
.add(
|
|
&mut state.rng(),
|
|
&state.clock,
|
|
&client,
|
|
"https://example.com/redirect".parse().unwrap(),
|
|
Scope::from_iter([OPENID]),
|
|
Some(AuthorizationCode {
|
|
code: code.to_owned(),
|
|
pkce: None,
|
|
}),
|
|
Some("state".to_owned()),
|
|
Some("nonce".to_owned()),
|
|
None,
|
|
ResponseMode::Query,
|
|
false,
|
|
false,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let session = repo
|
|
.oauth2_session()
|
|
.add_from_browser_session(
|
|
&mut state.rng(),
|
|
&state.clock,
|
|
&client,
|
|
&browser_session,
|
|
grant.scope.clone(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
// And fulfill it
|
|
let grant = repo
|
|
.oauth2_authorization_grant()
|
|
.fulfill(&state.clock, &session, grant)
|
|
.await
|
|
.unwrap();
|
|
|
|
repo.save().await.unwrap();
|
|
|
|
// Now call the token endpoint to get an access token.
|
|
let request =
|
|
Request::post(mas_router::OAuth2TokenEndpoint::PATH).form(serde_json::json!({
|
|
"grant_type": "authorization_code",
|
|
"code": code,
|
|
"redirect_uri": grant.redirect_uri,
|
|
"client_id": client.client_id,
|
|
}));
|
|
|
|
let response = state.request(request).await;
|
|
response.assert_status(StatusCode::OK);
|
|
|
|
let AccessTokenResponse { access_token, .. } = response.json();
|
|
|
|
// Check that the token is valid
|
|
assert!(state.is_access_token_valid(&access_token).await);
|
|
|
|
// Exchange it again, this it should fail
|
|
let request =
|
|
Request::post(mas_router::OAuth2TokenEndpoint::PATH).form(serde_json::json!({
|
|
"grant_type": "authorization_code",
|
|
"code": code,
|
|
"redirect_uri": grant.redirect_uri,
|
|
"client_id": client.client_id,
|
|
}));
|
|
|
|
let response = state.request(request).await;
|
|
response.assert_status(StatusCode::BAD_REQUEST);
|
|
let error: ClientError = response.json();
|
|
assert_eq!(error.error, ClientErrorCode::InvalidGrant);
|
|
|
|
// The token should still be valid
|
|
assert!(state.is_access_token_valid(&access_token).await);
|
|
|
|
// Now wait a bit
|
|
state.clock.advance(Duration::minutes(1));
|
|
|
|
// Exchange it again, this it should fail
|
|
let request =
|
|
Request::post(mas_router::OAuth2TokenEndpoint::PATH).form(serde_json::json!({
|
|
"grant_type": "authorization_code",
|
|
"code": code,
|
|
"redirect_uri": grant.redirect_uri,
|
|
"client_id": client.client_id,
|
|
}));
|
|
|
|
let response = state.request(request).await;
|
|
response.assert_status(StatusCode::BAD_REQUEST);
|
|
let error: ClientError = response.json();
|
|
assert_eq!(error.error, ClientErrorCode::InvalidGrant);
|
|
|
|
// And it should have revoked the token we got
|
|
assert!(!state.is_access_token_valid(&access_token).await);
|
|
|
|
// Try another one and wait for too long before exchanging it
|
|
let mut repo = state.repository().await.unwrap();
|
|
let code = "thisisanothercode";
|
|
let grant = repo
|
|
.oauth2_authorization_grant()
|
|
.add(
|
|
&mut state.rng(),
|
|
&state.clock,
|
|
&client,
|
|
"https://example.com/redirect".parse().unwrap(),
|
|
Scope::from_iter([OPENID]),
|
|
Some(AuthorizationCode {
|
|
code: code.to_owned(),
|
|
pkce: None,
|
|
}),
|
|
Some("state".to_owned()),
|
|
Some("nonce".to_owned()),
|
|
None,
|
|
ResponseMode::Query,
|
|
false,
|
|
false,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let session = repo
|
|
.oauth2_session()
|
|
.add_from_browser_session(
|
|
&mut state.rng(),
|
|
&state.clock,
|
|
&client,
|
|
&browser_session,
|
|
grant.scope.clone(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
// And fulfill it
|
|
let grant = repo
|
|
.oauth2_authorization_grant()
|
|
.fulfill(&state.clock, &session, grant)
|
|
.await
|
|
.unwrap();
|
|
|
|
repo.save().await.unwrap();
|
|
|
|
// Now wait a bit
|
|
state.clock.advance(Duration::minutes(15));
|
|
|
|
// Exchange it, it should fail
|
|
let request =
|
|
Request::post(mas_router::OAuth2TokenEndpoint::PATH).form(serde_json::json!({
|
|
"grant_type": "authorization_code",
|
|
"code": code,
|
|
"redirect_uri": grant.redirect_uri,
|
|
"client_id": client.client_id,
|
|
}));
|
|
|
|
let response = state.request(request).await;
|
|
response.assert_status(StatusCode::BAD_REQUEST);
|
|
let ClientError { error, .. } = response.json();
|
|
assert_eq!(error, ClientErrorCode::InvalidGrant);
|
|
}
|
|
|
|
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
|
|
async fn test_refresh_token_grant(pool: PgPool) {
|
|
init_tracing();
|
|
let state = TestState::from_pool(pool).await.unwrap();
|
|
|
|
// Provision a client
|
|
let request =
|
|
Request::post(mas_router::OAuth2RegistrationEndpoint::PATH).json(serde_json::json!({
|
|
"client_uri": "https://example.com/",
|
|
"redirect_uris": ["https://example.com/callback"],
|
|
"contacts": ["contact@example.com"],
|
|
"token_endpoint_auth_method": "none",
|
|
"response_types": ["code"],
|
|
"grant_types": ["authorization_code"],
|
|
}));
|
|
|
|
let response = state.request(request).await;
|
|
response.assert_status(StatusCode::CREATED);
|
|
|
|
let ClientRegistrationResponse { client_id, .. } = response.json();
|
|
|
|
// Let's provision a user and create a session for them. This part is hard to
|
|
// test with just HTTP requests, so we'll use the repository directly.
|
|
let mut repo = state.repository().await.unwrap();
|
|
|
|
let user = repo
|
|
.user()
|
|
.add(&mut state.rng(), &state.clock, "alice".to_owned())
|
|
.await
|
|
.unwrap();
|
|
|
|
let browser_session = repo
|
|
.browser_session()
|
|
.add(&mut state.rng(), &state.clock, &user, None)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Lookup the client in the database.
|
|
let client = repo
|
|
.oauth2_client()
|
|
.find_by_client_id(&client_id)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
|
|
// Get a token pair
|
|
let session = repo
|
|
.oauth2_session()
|
|
.add_from_browser_session(
|
|
&mut state.rng(),
|
|
&state.clock,
|
|
&client,
|
|
&browser_session,
|
|
Scope::from_iter([OPENID]),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let (AccessToken { access_token, .. }, RefreshToken { refresh_token, .. }) =
|
|
generate_token_pair(
|
|
&mut state.rng(),
|
|
&state.clock,
|
|
&mut repo,
|
|
&session,
|
|
Duration::minutes(5),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
repo.save().await.unwrap();
|
|
|
|
// First check that the token is valid
|
|
assert!(state.is_access_token_valid(&access_token).await);
|
|
|
|
// Now call the token endpoint to get an access token.
|
|
let request =
|
|
Request::post(mas_router::OAuth2TokenEndpoint::PATH).form(serde_json::json!({
|
|
"grant_type": "refresh_token",
|
|
"refresh_token": refresh_token,
|
|
"client_id": client.client_id,
|
|
}));
|
|
|
|
let response = state.request(request).await;
|
|
response.assert_status(StatusCode::OK);
|
|
|
|
let old_access_token = access_token;
|
|
let old_refresh_token = refresh_token;
|
|
let response: AccessTokenResponse = response.json();
|
|
let access_token = response.access_token;
|
|
let refresh_token = response.refresh_token.expect("to have a refresh token");
|
|
|
|
// Check that the new token is valid
|
|
assert!(state.is_access_token_valid(&access_token).await);
|
|
|
|
// Check that the old token is no longer valid
|
|
assert!(!state.is_access_token_valid(&old_access_token).await);
|
|
|
|
// Call it again with the old token, it should fail
|
|
let request =
|
|
Request::post(mas_router::OAuth2TokenEndpoint::PATH).form(serde_json::json!({
|
|
"grant_type": "refresh_token",
|
|
"refresh_token": old_refresh_token,
|
|
"client_id": client.client_id,
|
|
}));
|
|
|
|
let response = state.request(request).await;
|
|
response.assert_status(StatusCode::BAD_REQUEST);
|
|
let ClientError { error, .. } = response.json();
|
|
assert_eq!(error, ClientErrorCode::InvalidGrant);
|
|
|
|
// Call it again with the new token, it should work
|
|
let request =
|
|
Request::post(mas_router::OAuth2TokenEndpoint::PATH).form(serde_json::json!({
|
|
"grant_type": "refresh_token",
|
|
"refresh_token": refresh_token,
|
|
"client_id": client.client_id,
|
|
}));
|
|
|
|
let response = state.request(request).await;
|
|
response.assert_status(StatusCode::OK);
|
|
let _: AccessTokenResponse = response.json();
|
|
}
|
|
|
|
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
|
|
async fn test_unsupported_grant(pool: PgPool) {
|
|
init_tracing();
|
|
let state = TestState::from_pool(pool).await.unwrap();
|
|
|
|
// Provision a client
|
|
let request =
|
|
Request::post(mas_router::OAuth2RegistrationEndpoint::PATH).json(serde_json::json!({
|
|
"client_uri": "https://example.com/",
|
|
"redirect_uris": ["https://example.com/callback"],
|
|
"contacts": ["contact@example.com"],
|
|
"token_endpoint_auth_method": "client_secret_post",
|
|
"grant_types": ["client_credentials"],
|
|
"response_types": [],
|
|
}));
|
|
|
|
let response = state.request(request).await;
|
|
response.assert_status(StatusCode::CREATED);
|
|
|
|
let response: ClientRegistrationResponse = response.json();
|
|
let client_id = response.client_id;
|
|
let client_secret = response.client_secret.expect("to have a client secret");
|
|
|
|
// Call the token endpoint with an unsupported grant type
|
|
let request =
|
|
Request::post(mas_router::OAuth2TokenEndpoint::PATH).form(serde_json::json!({
|
|
"grant_type": "client_credentials",
|
|
"client_id": client_id,
|
|
"client_secret": client_secret,
|
|
}));
|
|
|
|
let response = state.request(request).await;
|
|
response.assert_status(StatusCode::BAD_REQUEST);
|
|
let ClientError { error, .. } = response.json();
|
|
assert_eq!(error, ClientErrorCode::UnsupportedGrantType);
|
|
}
|
|
}
|