blob: de22232cdebf2ee173f81de036ad756b6560cb91 [file] [log] [blame]
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
//! This module contains methods for creating OAuth requests and interpreting
//! responses.
use crate::constants::{
FUCHSIA_CLIENT_ID, OAUTH_REVOCATION_URI, OAUTH_TOKEN_EXCHANGE_URI, REDIRECT_URI,
};
use crate::error::{AuthProviderError, ResultExt};
use crate::http::{HttpRequest, HttpRequestBuilder};
use failure::format_err;
use fidl_fuchsia_auth::AuthProviderStatus;
use hyper::StatusCode;
use log::warn;
use serde_derive::Deserialize;
use serde_json::from_str;
use std::borrow::Cow;
use std::collections::HashMap;
use url::{form_urlencoded, Url};
type AuthProviderResult<T> = Result<T, AuthProviderError>;
#[derive(Debug, PartialEq)]
pub struct AuthCode(pub String);
#[derive(Debug, PartialEq)]
pub struct RefreshToken(pub String);
#[derive(Debug, PartialEq)]
pub struct AccessToken(pub String);
/// Response type for Oauth access token requests with a refresh token.
#[derive(Debug, Deserialize)]
struct AccessTokenResponseWithRefreshToken {
pub access_token: String,
pub refresh_token: String,
}
/// Response type for Oauth access token requests where a refresh token is not expected.
#[derive(Debug, Deserialize)]
struct AccessTokenResponseWithoutRefreshToken {
pub access_token: String,
pub expires_in: u64,
}
/// Error response type for Oauth requests.
#[derive(Debug, Deserialize)]
struct OAuthErrorResponse {
pub error: String,
pub error_description: Option<String>,
}
/// Construct an Oauth access token request using an authorization code.
pub fn build_request_with_auth_code(auth_code: AuthCode) -> AuthProviderResult<HttpRequest> {
let request_body = form_urlencoded::Serializer::new(String::new())
.append_pair("code", auth_code.0.as_str())
.append_pair("redirect_uri", REDIRECT_URI.as_str())
.append_pair("client_id", FUCHSIA_CLIENT_ID)
.append_pair("grant_type", "authorization_code")
.finish();
HttpRequestBuilder::new(OAUTH_TOKEN_EXCHANGE_URI.as_str(), "POST")
.with_header("content-type", "application/x-www-form-urlencoded")
.set_body(&request_body)
.finish()
}
/// Construct an Oauth access token request using a refresh token grant. If
/// `client_id` is not given the Fuchsia client id is used.
pub fn build_request_with_refresh_token(
refresh_token: RefreshToken,
scopes: Vec<String>,
client_id: Option<String>,
) -> AuthProviderResult<HttpRequest> {
let request_body = form_urlencoded::Serializer::new(String::new())
.append_pair("refresh_token", refresh_token.0.as_str())
.append_pair("client_id", client_id.as_ref().map_or(FUCHSIA_CLIENT_ID, String::as_str))
.append_pair("grant_type", "refresh_token")
.append_pair("scope", &scopes.join(" "))
.finish();
HttpRequestBuilder::new(OAUTH_TOKEN_EXCHANGE_URI.as_str(), "POST")
.with_header("content-type", "application/x-www-form-urlencoded")
.set_body(&request_body)
.finish()
}
/// Construct an Oauth token revocation request. `credential` may be either
/// an access token or refresh token.
pub fn build_revocation_request(credential: String) -> AuthProviderResult<HttpRequest> {
let request_body =
form_urlencoded::Serializer::new(String::new()).append_pair("token", &credential).finish();
HttpRequestBuilder::new(OAUTH_REVOCATION_URI.as_str(), "POST")
.with_header("content-type", "application/x-www-form-urlencoded")
.set_body(&request_body)
.finish()
}
/// Parses a response for an OAuth access token request when both a refresh token
/// and access token are expected in the response.
pub fn parse_response_with_refresh_token(
response_body: Option<String>,
status: StatusCode,
) -> AuthProviderResult<(RefreshToken, AccessToken)> {
match (response_body.as_ref(), status) {
(Some(response), StatusCode::OK) => {
let response = from_str::<AccessTokenResponseWithRefreshToken>(&response)
.auth_provider_status(AuthProviderStatus::OauthServerError)?;
Ok((RefreshToken(response.refresh_token), AccessToken(response.access_token)))
}
(Some(response), status) if status.is_client_error() => {
let error_response = from_str::<OAuthErrorResponse>(&response)
.auth_provider_status(AuthProviderStatus::OauthServerError)?;
let status = match error_response.error.as_str() {
"invalid_grant" => AuthProviderStatus::ReauthRequired,
error_code => {
warn!("Got unexpected error code during auth code exchange: {}", error_code);
AuthProviderStatus::OauthServerError
}
};
Err(AuthProviderError::new(status))
}
_ => Err(AuthProviderError::new(AuthProviderStatus::OauthServerError)),
}
}
/// Parses a response for an OAuth access token request when a refresh token is
/// not expected. Returns an access token and the lifetime of the token.
pub fn parse_response_without_refresh_token(
response_body: Option<String>,
status: StatusCode,
) -> AuthProviderResult<(AccessToken, u64)> {
match (response_body.as_ref(), status) {
(Some(response), StatusCode::OK) => {
let response = from_str::<AccessTokenResponseWithoutRefreshToken>(&response)
.auth_provider_status(AuthProviderStatus::OauthServerError)?;
Ok((AccessToken(response.access_token), response.expires_in))
}
(Some(response), status) if status.is_client_error() => {
let response = from_str::<OAuthErrorResponse>(&response)
.auth_provider_status(AuthProviderStatus::OauthServerError)?;
let status = match response.error.as_str() {
"invalid_grant" => AuthProviderStatus::ReauthRequired,
error_code => {
warn!("Got unexpected error code from access token request: {}", error_code);
AuthProviderStatus::OauthServerError
}
};
Err(AuthProviderError::new(status))
}
_ => Err(AuthProviderError::new(AuthProviderStatus::OauthServerError)),
}
}
/// Parses a response for an Oauth revocation request.
pub fn parse_revocation_response(
response_body: Option<String>,
status: StatusCode,
) -> AuthProviderResult<()> {
match (response_body.as_ref(), status) {
(_, StatusCode::OK) => Ok(()),
(Some(response), status) if status.is_client_error() => {
let response = from_str::<OAuthErrorResponse>(&response)
.auth_provider_status(AuthProviderStatus::OauthServerError)?;
warn!("Got unexpected error code during token revocation: {}", response.error);
Err(AuthProviderError::new(AuthProviderStatus::OauthServerError))
}
_ => Err(AuthProviderError::new(AuthProviderStatus::OauthServerError)),
}
}
/// Parses an auth code out of a redirect URL reached through an OAuth
/// authorization flow.
pub fn parse_auth_code_from_redirect(url: Url) -> AuthProviderResult<AuthCode> {
if (url.scheme(), url.domain(), url.path())
!= (REDIRECT_URI.scheme(), REDIRECT_URI.domain(), REDIRECT_URI.path())
{
return Err(AuthProviderError::new(AuthProviderStatus::InternalError)
.with_cause(format_err!("Redirected to unexpected URL")));
}
let params = url.query_pairs().collect::<HashMap<Cow<str>, Cow<str>>>();
if let Some(auth_code) = params.get("code") {
Ok(AuthCode(auth_code.as_ref().to_string()))
} else if let Some(error_code) = params.get("error") {
let error_status = match error_code.as_ref() {
"access_denied" => AuthProviderStatus::UserCancelled,
"server_error" => AuthProviderStatus::OauthServerError,
"temporarily_unavailable" => AuthProviderStatus::OauthServerError,
_ => AuthProviderStatus::UnknownError,
};
Err(AuthProviderError::new(error_status))
} else {
Err(AuthProviderError::new(AuthProviderStatus::UnknownError)
.with_cause(format_err!("Authorize redirect contained neither code nor error")))
}
}
#[cfg(test)]
mod test {
use super::*;
fn url_with_query(url_base: &Url, query: &str) -> Url {
let mut url = url_base.clone();
url.set_query(Some(query));
url
}
#[test]
fn test_parse_response_with_refresh_token_success() {
let response_body = Some(
"{\"refresh_token\": \"test-refresh-token\", \"access_token\": \"test-access-token\"}"
.to_string(),
);
assert_eq!(
(
RefreshToken("test-refresh-token".to_string()),
AccessToken("test-access-token".to_string())
),
parse_response_with_refresh_token(response_body, StatusCode::OK).unwrap()
)
}
#[test]
fn test_parse_response_with_refresh_token_failures() {
// Expired auth token
let response =
"{\"error\": \"invalid_grant\", \"error_description\": \"ouch\"}".to_string();
let result = parse_response_with_refresh_token(Some(response), StatusCode::BAD_REQUEST);
assert_eq!(result.unwrap_err().status, AuthProviderStatus::ReauthRequired);
// Server side error
let result = parse_response_with_refresh_token(None, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(result.unwrap_err().status, AuthProviderStatus::OauthServerError);
// Invalid client error
let response = "{\"error\": \"invalid_client\"}".to_string();
let result = parse_response_with_refresh_token(Some(response), StatusCode::UNAUTHORIZED);
assert_eq!(result.unwrap_err().status, AuthProviderStatus::OauthServerError);
// Malformed response
let response = "{\"a malformed response\"}".to_string();
let result = parse_response_with_refresh_token(Some(response), StatusCode::OK);
assert_eq!(result.unwrap_err().status, AuthProviderStatus::OauthServerError);
}
#[test]
fn test_parse_response_without_refresh_token_success() {
let response_body =
Some("{\"access_token\": \"test-access-token\", \"expires_in\": 3600}".to_string());
assert_eq!(
(AccessToken("test-access-token".to_string()), 3600),
parse_response_without_refresh_token(response_body, StatusCode::OK).unwrap()
)
}
#[test]
fn test_parse_response_without_refresh_token_failures() {
// Expired auth token
let response =
"{\"error\": \"invalid_grant\", \"error_description\": \"expired\"}".to_string();
let result = parse_response_without_refresh_token(Some(response), StatusCode::BAD_REQUEST);
assert_eq!(result.unwrap_err().status, AuthProviderStatus::ReauthRequired);
// Server side error
let result = parse_response_without_refresh_token(None, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(result.unwrap_err().status, AuthProviderStatus::OauthServerError);
// Invalid client error
let response = "{\"error\": \"invalid_client\"}".to_string();
let result = parse_response_without_refresh_token(Some(response), StatusCode::UNAUTHORIZED);
assert_eq!(result.unwrap_err().status, AuthProviderStatus::OauthServerError);
// Malformed response
let response = "{\"a malformed response\"}".to_string();
let result = parse_response_without_refresh_token(Some(response), StatusCode::OK);
assert_eq!(result.unwrap_err().status, AuthProviderStatus::OauthServerError);
}
#[test]
fn test_parse_revocation_response_success() {
assert!(parse_revocation_response(None, StatusCode::OK).is_ok());
}
#[test]
fn test_parse_revocation_response_failures() {
// Server side error
let result = parse_revocation_response(None, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(result.unwrap_err().status, AuthProviderStatus::OauthServerError);
// Malformed response
let response = "bad response".to_string();
let result = parse_revocation_response(Some(response), StatusCode::BAD_REQUEST);
assert_eq!(result.unwrap_err().status, AuthProviderStatus::OauthServerError);
}
#[test]
fn test_auth_code_from_redirect() {
// Success case
let success_url = url_with_query(&REDIRECT_URI, "code=test-auth-code");
assert_eq!(
AuthCode("test-auth-code".to_string()),
parse_auth_code_from_redirect(success_url).unwrap()
);
// Access denied case
let canceled_url = url_with_query(&REDIRECT_URI, "error=access_denied");
assert_eq!(
AuthProviderStatus::UserCancelled,
parse_auth_code_from_redirect(canceled_url).unwrap_err().status
);
// Unexpected redirect
let error_url = Url::parse("ftp://incorrect/some-page").unwrap();
assert_eq!(
AuthProviderStatus::InternalError,
parse_auth_code_from_redirect(error_url).unwrap_err().status
);
// Unknown error
let invalid_url = url_with_query(&REDIRECT_URI, "error=invalid_request");
assert_eq!(
AuthProviderStatus::UnknownError,
parse_auth_code_from_redirect(invalid_url).unwrap_err().status
);
// No code or error in url.
assert_eq!(
AuthProviderStatus::UnknownError,
parse_auth_code_from_redirect(REDIRECT_URI.clone()).unwrap_err().status
);
}
}