openauth-plugins 0.0.4

Official OpenAuth plugin modules.
Documentation
use std::sync::Arc;

use http::{Method, StatusCode};
use openauth_core::api::{
    create_auth_endpoint, parse_request_body, ApiRequest, ApiResponse, AuthEndpointOptions,
    BodyField, BodySchema, JsonSchemaType, OpenApiOperation,
};
use openauth_core::auth::oauth::{
    handle_oauth_user_info, HandleOAuthUserInfoInput, OAuthAccountInput, OAuthUserInfo,
    OAuthUserInfoError,
};
use openauth_core::context::request_state::{has_request_state, set_current_new_session};
use openauth_core::context::AuthContext;
use openauth_core::error::OpenAuthError;
use openauth_core::oauth::oauth2::{
    ClientId, OAuth2Tokens, OAuth2UserInfo, ProviderOptions, SocialIdTokenRequest,
    SocialOAuthProvider,
};
use openauth_social_providers::google::{google, GoogleOptions};
use serde::Deserialize;
use serde_json::json;

use super::options::OneTapOptions;
use super::response::{error_response, session_response};

const GOOGLE_PROVIDER_ID: &str = "google";
const GOOGLE_SCOPE: &str = "openid,profile,email";

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct OneTapCallbackBody {
    id_token: String,
}

pub(super) fn one_tap_callback_endpoint(
    options: OneTapOptions,
) -> openauth_core::api::AsyncAuthEndpoint {
    create_auth_endpoint(
        "/one-tap/callback",
        Method::POST,
        AuthEndpointOptions::new()
            .operation_id("oneTapCallback")
            .allowed_media_types(["application/x-www-form-urlencoded", "application/json"])
            .body_schema(one_tap_callback_body_schema())
            .openapi(
                OpenApiOperation::new("oneTapCallback")
                    .description("Authenticate with Google One Tap")
                    .response(
                        "200",
                        json!({
                            "description": "Successful response",
                            "content": {
                                "application/json": {
                                    "schema": {
                                        "type": "object",
                                        "properties": {
                                            "token": { "type": "string" },
                                            "user": { "$ref": "#/components/schemas/User" }
                                        }
                                    }
                                }
                            }
                        }),
                    )
                    .response(
                        "400",
                        json!({
                            "description": "Invalid token",
                        }),
                    ),
            ),
        move |context, request| {
            let options = options.clone();
            Box::pin(async move { handle_one_tap_callback(context, request, options).await })
        },
    )
}

async fn handle_one_tap_callback(
    context: &AuthContext,
    request: ApiRequest,
    options: OneTapOptions,
) -> Result<ApiResponse, OpenAuthError> {
    let adapter = context.adapter().ok_or_else(|| {
        OpenAuthError::InvalidConfig(
            "one-tap callback requires an adapter-backed OpenAuth instance".to_owned(),
        )
    })?;
    let body: OneTapCallbackBody = parse_request_body(&request)?;
    let provider = google_provider(context, &options)?;
    let tokens = OAuth2Tokens {
        id_token: Some(body.id_token.clone()),
        scopes: google_scopes(),
        ..OAuth2Tokens::default()
    };

    if !provider
        .verify_id_token(SocialIdTokenRequest {
            token: body.id_token.clone(),
            ..SocialIdTokenRequest::default()
        })
        .await?
    {
        return error_response(StatusCode::BAD_REQUEST, "INVALID_TOKEN", "invalid id token");
    }

    let Some(user_info) = provider.get_user_info(tokens.clone(), None).await? else {
        return error_response(
            StatusCode::BAD_REQUEST,
            "EMAIL_NOT_AVAILABLE",
            "Email not available in token",
        );
    };
    let Some(normalized) = normalize_user_info(&user_info) else {
        return error_response(
            StatusCode::BAD_REQUEST,
            "EMAIL_NOT_AVAILABLE",
            "Email not available in token",
        );
    };
    let result = handle_oauth_user_info(
        context,
        adapter.as_ref(),
        HandleOAuthUserInfoInput {
            account: oauth_account(&user_info, &tokens),
            user_info: normalized,
            disable_sign_up: options.disable_signup,
            override_user_info: provider.provider_options().override_user_info_on_sign_in,
            is_trusted_provider: true,
            ..HandleOAuthUserInfoInput::default()
        },
    )
    .await?;

    let Some(data) = result.data else {
        let error = result
            .error
            .unwrap_or(OAuthUserInfoError::UnableToCreateUser);
        return oauth_error_response(error);
    };

    if has_request_state() {
        set_current_new_session(data.session.clone(), data.user.clone())?;
    }

    session_response(
        context,
        adapter.as_ref(),
        data.session,
        data.user,
        result.cookies,
    )
    .await
}

fn google_provider(
    context: &AuthContext,
    options: &OneTapOptions,
) -> Result<Arc<dyn SocialOAuthProvider>, OpenAuthError> {
    if let Some(client_id) = options.client_id.as_ref().filter(|value| !value.is_empty()) {
        let provider_options = context
            .social_provider(GOOGLE_PROVIDER_ID)
            .map(|provider| provider.provider_options())
            .unwrap_or_default();
        return Ok(Arc::new(google(GoogleOptions {
            oauth: ProviderOptions {
                client_id: Some(ClientId::Single(client_id.clone())),
                ..provider_options
            },
            ..GoogleOptions::default()
        })));
    }

    context.social_provider(GOOGLE_PROVIDER_ID).ok_or_else(|| {
        OpenAuthError::InvalidConfig("one-tap requires a configured google provider".to_owned())
    })
}

fn normalize_user_info(info: &OAuth2UserInfo) -> Option<OAuthUserInfo> {
    let email = info.email.clone()?;
    Some(OAuthUserInfo {
        id: info.id.clone(),
        name: info.name.clone().unwrap_or_default(),
        email,
        image: info.image.clone(),
        email_verified: info.email_verified,
    })
}

fn oauth_account(info: &OAuth2UserInfo, tokens: &OAuth2Tokens) -> OAuthAccountInput {
    OAuthAccountInput {
        provider_id: GOOGLE_PROVIDER_ID.to_owned(),
        account_id: info.id.clone(),
        id_token: tokens.id_token.clone(),
        scope: Some(GOOGLE_SCOPE.to_owned()),
        ..OAuthAccountInput::default()
    }
}

fn oauth_error_response(error: OAuthUserInfoError) -> Result<ApiResponse, OpenAuthError> {
    match error {
        OAuthUserInfoError::SignupDisabled => {
            error_response(StatusCode::BAD_GATEWAY, "SIGNUP_DISABLED", "User not found")
        }
        OAuthUserInfoError::AccountNotLinked => error_response(
            StatusCode::UNAUTHORIZED,
            "ACCOUNT_NOT_LINKED",
            "Google sub doesn't match",
        ),
        OAuthUserInfoError::UnableToCreateUser => error_response(
            StatusCode::INTERNAL_SERVER_ERROR,
            "UNABLE_TO_CREATE_USER",
            "Could not create user",
        ),
        OAuthUserInfoError::UnableToCreateSession => error_response(
            StatusCode::INTERNAL_SERVER_ERROR,
            "UNABLE_TO_CREATE_SESSION",
            "Could not create session",
        ),
        OAuthUserInfoError::UnableToLinkAccount => error_response(
            StatusCode::INTERNAL_SERVER_ERROR,
            "UNABLE_TO_LINK_ACCOUNT",
            "Could not link account",
        ),
    }
}

fn one_tap_callback_body_schema() -> BodySchema {
    BodySchema::object([BodyField::new("idToken", JsonSchemaType::String)
        .description("Google ID token, which the client obtains from the One Tap API")])
}

fn google_scopes() -> Vec<String> {
    ["openid", "profile", "email"]
        .into_iter()
        .map(str::to_owned)
        .collect()
}