kurtbuilds / httpclient_oauth2

2 stars 0 forks source link

Cant get middleware handle to run #1

Open RobertoSnap opened 8 months ago

RobertoSnap commented 8 months ago

First, thank you for a fantastic create. Discovered it through libninja.

So I have used Libninja to create an API for a service here in Norway.

Then in a axum endpoint i try and create a client and the middleware.

    let flow = shared_oauth2_flow();
    let mut mw = flow.bearer_middleware(keys.access_token.clone(), keys.refresh_token.clone());
    mw.callback(|refresh| {
        println!("Refresh token: {:?}", refresh);
    });
    let fiken_auth = FikenAuth::OAuth2 {
        middleware: std::sync::Arc::new(mw),
    };
    let fiken_client = FikenClient::with_auth(fiken_auth);

I then run a request and it works as expected until the access token expires.

After the access token expires, i get this panic:

2024-03-08T07:57:28.323009Z  INFO frakty: listening on localhost:2323
Inserting OAuth middleware // Just something i added
[src/integrations/fiken/mod.rs:85] middleware = OAuth2 {
    refresh_endpoint: "https://fiken.no/oauth/token",
    client_id: "REDACTED",
    client_secret: "REDACTED",
    token_type: Bearer,
    access_token: RwLock {
        data: "REDACTED",
        poisoned: false,
        ..
    },
    refresh_token: "REDACTED",
    callback: Some(
        "Fn(RefreshData)",
    ),
}
thread 'tokio-runtime-worker' panicked at src/api/router/integration/fiken.rs:113:14:
Failed to get sales: HttpError(Response { status: 401, version: HTTP/1.1, headers: {"server": "nginx/1.18.0 (Ubuntu)", "date": "Fri, 08 Mar 2024 07:57:31 GMT", "content-type": "application/vnd.error+json;charset=utf-8", "transfer-encoding": "chunked", "connection": "keep-alive", "www-authenticate": "Bearer error=\"invalid_token\"", "content-language": "nb-NO", "cache-control": "no-store"}, body: Bytes([123, ...]) })

But why is the request not first run through the OAuth middleware?

RobertoSnap commented 8 months ago

Hemm, it seem that its rather the oauth handle that is not compatible by the service im communicating with. Think i need to create my own middleware.

kurtbuilds commented 8 months ago

Thank you for the kind words.

At a high level it looks like you're using it correctly. In your update, are you saying the service implements something similar to oauth, but not oauth exactly?

If it's still oauth, but there's some support missing in this lib, happy to help.

RobertoSnap commented 8 months ago

Yea, exactly, i dont think what they require is oAuth. They require that i set a Basic auth from client and secret, and accept header to appication/json:

Like this

         .header("accept", "application/json")
        .content_type("application/x-www-form-urlencoded")
        .basic_auth(&create_basic_auth(&self.client_id, &self.client_secret))

I dont know if this is general or specific in regards to other services and worth supporting. Im creating an extension to oauth and oauth flow now.

RobertoSnap commented 8 months ago

It went very smooth creating a modification of the oauth and oauth-flow (thanks to your awesome code).

Tell me if this is something general and i can see how its possible to create it as params in a PR.

flow

use std::io::ErrorKind;
use std::sync::RwLock;

use httpclient::InMemoryResponseExt;
use httpclient::{client, InMemoryBody, InMemoryResult, Result, Uri};
use serde::de;
use serde::{Deserialize, Serialize};
use serde_json::json;

use crate::integrations::fiken::oauth::{FikenOAuth, TokenType};
use crate::utils::{
    credentials::create_basic_auth,
    random::{create_random_value_with_secret, verify_random_value_with_secret},
};

pub struct FikenOAuth2Flow {
    pub client_id: String,
    pub client_secret: String,

    /// The endpoint to initialize the flow. (Step 1)
    pub init_endpoint: String,
    /// The endpoint to exchange the code for an access token. (Step 2)
    pub exchange_endpoint: String,
    /// The endpoint to refresh the access token.
    pub refresh_endpoint: String,

    pub redirect_uri: String,
}

#[derive(Debug, Serialize)]
pub(super) struct TokenEndpointParams<'a> {
    pub client_id: &'a str,
    pub redirect_uri: &'a str,
    /// value should be "code". TODO to remove the field from the struct
    pub response_type: &'static str,
    pub access_type: &'static str,
}

#[derive(Deserialize, Serialize, Debug)]
pub struct FikenTokenEndpointBody {
    code: String,
    redirect_uri: String,
    grant_type: String,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct ExchangeResponse {
    pub access_token: String,
    pub expires_in: u64,
    pub refresh_token: Option<String>,
    pub scope: Option<String>,
    pub token_type: String,
}

impl FikenOAuth2Flow {
    pub fn create_authorization_url(&self) -> Uri {
        let state = create_random_value_with_secret();
        let params = TokenEndpointParams {
            client_id: &self.client_id,
            redirect_uri: &self.redirect_uri,
            response_type: "code",
            access_type: "authorization_code",
        };
        let params = serde_qs::to_string(&params).unwrap();
        let endpoint = self.init_endpoint.as_str();
        let uri = format!("{endpoint}?{params}");
        uri.parse().unwrap()
    }

    pub async fn exchange(&self, code: String, state: String) -> InMemoryResult<ExchangeResponse> {
        if !verify_random_value_with_secret(state.as_str()) {
            panic!("Invalid state");
        }
        let res = client()
            .post(&self.exchange_endpoint)
            .form(FikenTokenEndpointBody {
                code: code,
                redirect_uri: self.redirect_uri.clone(),
                grant_type: "authorization_code".to_string(),
            })
            .header("accept", "application/json")
            .content_type("application/x-www-form-urlencoded")
            .basic_auth(&create_basic_auth(&self.client_id, &self.client_secret))
            .await?;
        Ok(res.json()?)
    }
    pub fn bearer_middleware(&self, access: String, refresh: String) -> FikenOAuth {
        FikenOAuth {
            refresh_endpoint: self.refresh_endpoint.clone(),
            client_id: self.client_id.clone(),
            client_secret: self.client_secret.clone(),
            token_type: TokenType::Bearer,
            access_token: RwLock::new(access),
            refresh_token: refresh,
            callback: None,
        }
    }
}

oauth


use std::fmt::{Debug, Formatter};
use std::sync::RwLock;

use async_trait::async_trait;
use serde::{Deserialize, Serialize};

use httpclient::{
    header, HeaderName, InMemoryRequest, Method, Middleware, Next, ProtocolResult, RequestBuilder,
    Response,
};

use crate::utils::credentials::create_basic_auth;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum TokenType {
    Bearer,
    #[serde(untagged)]
    Other(String),
}

#[derive(Debug, Serialize, Deserialize)]
pub struct RefreshData {
    pub access_token: String,
}

#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct RefreshResponse {
    pub access_token: String,
    pub expires_in: u64,
    pub scope: Option<String>,
    pub token_type: TokenType,
}

// const NO_OP: fn(RefreshData) = |_: RefreshData| {};
// fn(..) -> ()
// Fn()
// FnMut()
// FnOnce()

pub struct FikenOAuth {
    // Configuration
    pub refresh_endpoint: String,
    pub client_id: String,
    pub client_secret: String,
    pub token_type: TokenType,

    // State
    pub access_token: RwLock<String>,
    pub refresh_token: String,

    pub callback: Option<Box<dyn Fn(RefreshData) + Send + Sync + 'static>>,
}

impl Debug for FikenOAuth {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("OAuth2")
            .field("refresh_endpoint", &self.refresh_endpoint)
            .field("client_id", &self.client_id)
            .field("client_secret", &self.client_secret)
            .field("token_type", &self.token_type)
            .field("access_token", &self.access_token)
            .field("refresh_token", &self.refresh_token)
            .field(
                "callback",
                &self.callback.as_ref().map(|_| "Fn(RefreshData)"),
            )
            .finish()
    }
}

impl FikenOAuth {
    pub fn callback(&mut self, data: impl Fn(RefreshData) + Send + Sync + 'static) {
        self.callback = Some(Box::new(data));
    }

    fn authorize(&self, mut request: InMemoryRequest) -> InMemoryRequest {
        let access_token = self.access_token.read().unwrap();
        let access_token = access_token.as_str();
        match &self.token_type {
            TokenType::Bearer => {
                request.headers_mut().insert(
                    header::AUTHORIZATION,
                    format!("Bearer {}", access_token).parse().unwrap(),
                );
            }
            TokenType::Other(s) => {
                request.headers_mut().insert(
                    s.parse::<HeaderName>().unwrap(),
                    access_token.parse().unwrap(),
                );
            }
        }
        request
    }
}

#[async_trait]
impl Middleware for FikenOAuth {
    async fn handle(&self, request: InMemoryRequest, next: Next<'_>) -> ProtocolResult<Response> {
        let req = self.authorize(request);
        let res = next.run(req.clone().into()).await;
        if !matches!(&res, Ok(resp) if resp.status().as_u16() == 401) {
            // if we didn't get a 401, proceed as normal
            return res;
        }
        let refresh_req = RequestBuilder::new(
            next.client,
            Method::POST,
            self.refresh_endpoint.parse().unwrap(),
        )
        .form(RefreshRequest {
            grant_type: "refresh_token",
            refresh_token: &self.refresh_token,
        })
        .header("accept", "application/json")
        .content_type("application/x-www-form-urlencoded")
        .basic_auth(&create_basic_auth(&self.client_id, &self.client_secret))
        .build();
        let res = next.run(refresh_req).await?;
        let (_, body) = res.into_parts();
        let body = body.into_memory().await?;
        let data: RefreshResponse = body.json()?;
        {
            let mut access_token = self.access_token.write().unwrap();
            *access_token = data.access_token.clone();
        }
        if let Some(callback) = self.callback.as_ref() {
            callback(RefreshData {
                access_token: data.access_token,
            });
        }
        // // reauthorize the request with the newly set access token. it will overwrite the previously set headers
        let req = self.authorize(req);
        next.run(req.clone().into()).await
    }
}

#[derive(Debug, Serialize)]
struct RefreshRequest<'a> {
    pub grant_type: &'static str,
    pub refresh_token: &'a str,
}
``
kurtbuilds commented 8 months ago

Thank you again!

The main difference is where the client_id & client_secret are passed in, correct? Do you have a sense of whether that's a common pattern for implementing OAuth? I've only encountered the one that is currently implemented in the library.

If it's common enough, it seems perfectly reasonable to provide both a FormOAuth2 middleware and a BasicAuthOAuth2 middleware (names are examples) in this lib, and a PR would be welcome.