From df41967b1910e339df8f8e1da85035d003af07d4 Mon Sep 17 00:00:00 2001 From: ryoNAKAYA Date: Sun, 6 Jul 2025 14:21:43 +0900 Subject: [PATCH 1/2] feat: add `easy` module and helper functions for easier OIDC flow Previously, the process of obtaining an `IDTokne` was declarative, but required many steps, making the code hard to write and maintain. The new `easy` module provides convenient helper functions that combine the plublic API to reduce boilerplate and simplify usage. --- src/easy.rs | 110 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 3 ++ 2 files changed, 113 insertions(+) create mode 100644 src/easy.rs diff --git a/src/easy.rs b/src/easy.rs new file mode 100644 index 0000000..b6d4dbe --- /dev/null +++ b/src/easy.rs @@ -0,0 +1,110 @@ +//! A module to simplify implementing the OIDC authentication flow. +//! +//! This module provides easy-to-use helper functions that combine the public API to reduce boilerplate. +//! It generates a CSRF token and a nonce internally, so these functions have some side effects. +//! +//! # Examples +//! See [example/easy/id_token](https://github.com/nakaryo716/tiny_google_oidc/examples/easy/id_token.rs) for usage examples. +use crate::{ + code::{AccessType, AdditionalScope, Code, CodeRequest, QueryExtractor, RawCodeResponse}, + config::Config, + csrf_token::CSRFToken, + error::Error, + id_token::IDTokenRequest, + nonce::Nonce, +}; + +/// Generates a redirect URI for obtaining an OIDC authorization code . +/// +/// Internally, this function generates a new [`CSRFToken`] and [`Nonce`], +/// then constructs a redirect URI according to the specified arguments. +/// You should store the CSRF token and nonce as needed for later validation. +/// +/// # Arguments +/// ## config +/// The OIDC provider configuration, contains Client ID, Secrets, etc... +/// ## access_type +/// If you want to obtain refresh token, set [`AccessType::Offline`]. +/// See [google document](https://developers.google.com/identity/openid-connect/openid-connect#refresh-tokens) +/// ## scope +/// Additional scopes to request. +/// - If the [`AdditionalScope::Profile`] scope value is present, the ID token might (but is not guaranteed to) +/// include the user's default profile claims. +/// - If the [`AdditionalScope::Email`] scope value is present, the ID token includes email and email_verified claims. +/// - If the [`AdditionalScope::Both`] scope value is present, the ID token includes +/// the user's default profile, email and email_verified claims. +/// - If the [`AdditionalScope::None`] scope value is present, the ID token dose not include any additional claims. +/// +/// See [google documentation](https://developers.google.com/identity/openid-connect/openid-connect#scope-param) +/// +/// # Returns +/// A tuple containing: +/// - 0: The generated CSRF token. +/// - 1: The generated nonce. +/// - 2: The redirect URI as a [`String`]. +/// +/// # Errors +/// Returns an error if token generation or URI construction fails. +/// +/// # Examples +/// ```no_run +/// let (csrf_token, nonce, uri) = generate_auth_redirect( +/// &config, +/// AccessType::Offline, +/// AdditionalScope::Both +/// )?; +/// ``` +pub fn generate_auth_redirect( + config: &Config, + access_type: AccessType, + scope: AdditionalScope, +) -> Result<(CSRFToken, Nonce, String), Error> { + let csrf_token = CSRFToken::new()?; + let nonce = Nonce::new(); + let code_req = CodeRequest::new(access_type, config, scope, &csrf_token, &nonce); + let uri = code_req.try_into_url()?.to_string(); + Ok((csrf_token, nonce, uri)) +} + +/// Creates an [`IDTokenRequest`] after validating the CSRF token. +/// +/// This function validates that the provided CSRF token string (usually stored on the server) +/// matches the one returned from the Google authentication response (given as `query_src`). +/// If the token matches, it constructs an [`IDTokenRequest`]; otherwise, it returns an error. +/// +/// # Arguments +/// ## `config` +/// The OIDC provider configuration. +/// ## `csrf_token_str` +/// The CSRF token string stored on the server. +/// ## `query_src` +/// A type that is implementing the [`QueryExtractor`] trait. +/// The trait method returns a URL-encoded query string that is returned from Google. +/// +/// # Returns +/// An [`IDTokenRequest`] if CSRF validation succeeds. +/// +/// # Errors +/// Returns an error if CSRF validation fails or if the query cannot be parsed. +/// +/// # Examples +/// ```no_run +/// fn callback_handler(config: &Config, stored_csrf_token: &str, req: Request) -> Result<(), Error> { +/// // http::Request is implemented QueryExtractor trait +/// let id_token_req = create_id_token_request( +/// config, +/// stored_csrf_token, +/// req +/// )?; +/// } +/// ``` +pub fn create_id_token_request<'a, Q: QueryExtractor>( + config: &'a Config, + csrf_token_str: &'a str, + query_src: Q, +) -> Result, Error> { + let raw_code = RawCodeResponse::new(query_src)?; + let code = Code::new_with_verify_csrf(raw_code, csrf_token_str)?; + let id_token_req = IDTokenRequest::new(config, code); + Ok(id_token_req) +} diff --git a/src/lib.rs b/src/lib.rs index 2b09c09..cdb1f9a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,8 +24,11 @@ pub mod code; pub mod config; pub mod csrf_token; +pub mod easy; pub mod error; pub mod id_token; pub mod nonce; pub mod refresh_token; pub mod revoke_token; + +pub use easy::{create_id_token_request, generate_auth_redirect}; From e15be523eae5ede8e59d85fe96a7050f45fd0a2c Mon Sep 17 00:00:00 2001 From: ryoNAKAYA Date: Sun, 6 Jul 2025 14:42:04 +0900 Subject: [PATCH 2/2] docs: add usage example for easy module --- Cargo.toml | 4 + examples/easy/id_token.rs | 205 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 examples/easy/id_token.rs diff --git a/Cargo.toml b/Cargo.toml index 6a6bea6..f49f19e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,3 +51,7 @@ path = "examples/hyper/main.rs" [[example]] name = "id_token" path = "examples/id_token.rs" + +[[example]] +name = "easy_id_token" +path = "examples/easy/id_token.rs" diff --git a/examples/easy/id_token.rs b/examples/easy/id_token.rs new file mode 100644 index 0000000..8520ebd --- /dev/null +++ b/examples/easy/id_token.rs @@ -0,0 +1,205 @@ +//! example of getting IDToken and print it. +//! +//! # Setup +//! 1. Create OAuth2.0 Client at google-cloud-console's credentials page +//! 2. Get your AUTH_ENDPOINT, CLIENT_ID, CLIENT_SECRET, TOKEN_ENDPOINT, REDIRECT_URI +//! 3. Set step 2's value as a static value down below +//! 4. Run with the following +//! ```not_rust +//! cargo run --example easy_id_token +//! ``` +//! 5. Access the endpoint that has a login handler in your browser(default: http://localhost) +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; + +use axum::{ + Router, + extract::{Request, State}, + response::{IntoResponse, Redirect, Response}, + routing::get, +}; +use axum_extra::extract::CookieJar; +use cookie::{ + Cookie, + time::{Duration, OffsetDateTime}, +}; +use http::StatusCode; +use tiny_google_oidc::{ + code::{AccessType, AdditionalScope}, + config::ConfigBuilder, + easy::{create_id_token_request, generate_auth_redirect}, + id_token::{IDToken, send_id_token_req}, +}; +use uuid::Uuid; + +// Fix this value CLIENT_ID, CLIENT_SECRET, REDIRECT_URI +static AUTH_ENDPOINT: &str = "https://accounts.google.com/o/oauth2/auth"; +static CLIENT_ID: &str = "my_client_id"; +static CLIENT_SECRET: &str = "my_client_secret"; +static TOKEN_ENDPOINT: &str = "https://oauth2.googleapis.com/token"; +static REDIRECT_URI: &str = "http://localhost/auth/callback"; + +static COOKIE_KEY: &str = "token"; + +#[tokio::main] +async fn main() { + // Construct Config by using your CLIENT_ID, SECRETS, etc... + let oidc_cfg = ConfigBuilder::new() + .auth_endpoint(AUTH_ENDPOINT) + .client_id(CLIENT_ID) + .client_secret(CLIENT_SECRET) + .token_endpoint(TOKEN_ENDPOINT) + .redirect_uri(REDIRECT_URI) + .build(); + + // Fix port number you set at google cloud console + let listener = tokio::net::TcpListener::bind("0.0.0.0:80").await.unwrap(); + + let app_state = Arc::new(AppState::new(oidc_cfg)); + + let app = Router::new() + .route("/", get(login)) + // If callback URI that you set is different, please fix + .route("/auth/callback", get(callback)) + .with_state(app_state); + + axum::serve(listener, app).await.unwrap(); +} + +#[derive(Debug)] +struct AppState { + state: Mutex>, + oidc_cfg: Arc, +} + +impl AppState { + fn new(oidc_cfg: tiny_google_oidc::config::Config) -> Self { + AppState { + state: Mutex::default(), + oidc_cfg: Arc::new(oidc_cfg), + } + } + + fn insert(&self, store_key: &str, csrf_token: &str) { + let mut guard = self.state.lock().unwrap(); + guard.insert(store_key.to_string(), csrf_token.to_string()); + } + + fn get(&self, store_key: &str) -> Option { + let guard = self.state.lock().unwrap(); + guard.get(store_key).map(|v| v.to_string()) + } +} + +async fn login( + State(app_state): State>, + jar: CookieJar, +) -> Result { + let (csrf_token, _nonce, redirect_uri) = generate_auth_redirect( + &app_state.oidc_cfg, + AccessType::Online, + AdditionalScope::Both, + ) + .map_err(WrapOIDCError)?; + + let csrf_token_store_key = Uuid::new_v4(); + app_state.insert(&csrf_token_store_key.to_string(), csrf_token.value()); + + // Create cookie that is holding a key which indicate csrf_token_value + let mut cookie = Cookie::new(COOKIE_KEY, csrf_token_store_key.to_string()); + cookie.set_http_only(true); + set_cookie_expires(&mut cookie, Duration::minutes(5)); + + // Set-Cookie ant Redirect as a response + Ok((jar.add(cookie), Redirect::to(&redirect_uri))) +} + +fn set_cookie_expires(cookie: &mut Cookie, delta: Duration) { + let time = OffsetDateTime::now_utc() + delta; + cookie.set_expires(time); +} + +async fn callback( + State(app_state): State>, + jar: CookieJar, + req: Request, +) -> Result { + let csrf_token_key = get_csrf_token_key_from_cookie(&jar)?; + // fetch csrf_token_val by using key that you got from cookie + let csrf_token_val = app_state + .get(&csrf_token_key) + .ok_or(AppError::GenURL)? + .to_owned(); + + let id_token_req = create_id_token_request(&app_state.oidc_cfg, &csrf_token_val, req) + .map_err(WrapOIDCError)?; + + // Send request to google + // This is effect + let id_token_res = send_id_token_req(&id_token_req) + .await + .map_err(WrapOIDCError)?; + + // print IDTokenResponse + // It has some value, such as refresh_token, access_token, etc... + println!("----IDTokenResponse----"); + println!("{id_token_res:#?}"); + + // encode IDToken from raw string + let id_token = IDToken::from_id_token_raw(id_token_res.id_token()).map_err(WrapOIDCError)?; + + // print id_token + println!("----IDToken----"); + println!("{id_token:#?}"); + + Ok((StatusCode::OK, "login success")) +} + +fn get_csrf_token_key_from_cookie(jar: &CookieJar) -> Result { + let cookie = jar.get(COOKIE_KEY).ok_or(AppError::CookieNotFound)?; + Ok(cookie.value_trimmed().to_string()) +} + +#[derive(Debug, Clone)] +enum AppError { + CookieNotFound, + GenURL, + CSRFNotMatch(String), + SendStatus((StatusCode, String)), + Others(tiny_google_oidc::error::Error), +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + match self { + AppError::CookieNotFound => { + { (StatusCode::BAD_REQUEST, "cookie not found to auth").into_response() } + .into_response() + } + AppError::GenURL => { + (StatusCode::INTERNAL_SERVER_ERROR, "failed to generate url").into_response() + } + AppError::Others(e) => { + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response() + } + AppError::CSRFNotMatch(e) => (StatusCode::BAD_REQUEST, e.to_string()).into_response(), + AppError::SendStatus((status, msg)) => (status, msg).into_response(), + } + } +} + +#[derive(Debug, Clone)] +struct WrapOIDCError(tiny_google_oidc::error::Error); + +use tiny_google_oidc::error::Error; +impl From for AppError { + fn from(value: WrapOIDCError) -> Self { + match value.0 { + Error::CSRFNotMatch => AppError::CSRFNotMatch(value.0.to_string()), + Error::SendStatus(status) => AppError::SendStatus((status, value.0.to_string())), + _ => AppError::Others(value.0), + } + } +}