Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement creation of custom token #150

Closed
lukemauldinks opened this issue Dec 31, 2023 · 21 comments
Closed

Implement creation of custom token #150

lukemauldinks opened this issue Dec 31, 2023 · 21 comments
Labels
enhancement New feature or request question Further information is requested

Comments

@lukemauldinks
Copy link

Implement functionality in Rust: CustomToken creates a signed custom authentication token with the specified user ID.

See Go reference code:
https://pkg.go.dev/firebase.google.com/go/[email protected]/auth#Client.CustomToken

@abdolence
Copy link
Owner

I think this is out of scope right now since that functionality is actually:

  • Part of Firebase Auth, not Firestore - this crate is designed to work with Firestore databases, not complete Firebase;
  • Firebase token generator is a legacy functionality, so you need to migrate to service accounts anyway;

In case you want to generate a token yourself the crate supports custom JSON tokens via:
FirestoreDb::with_options_token_source where you can specify any possible JWT token with TokenSourceType::Json.

Let me know if you have a specific case where this is somehow required for Firestore and FirestoreDb::with_options_token_source can't be used.

@abdolence abdolence added the question Further information is requested label Jan 1, 2024
@lukemauldinks
Copy link
Author

lukemauldinks commented Jan 1, 2024

I will provide some more context and hopefully that will be helpful. We have client applications using the Firebase client libraries to generate auth tokens for each user and the client applications are invoking our Rust backend http services and sending the Firebase auth tokens in the header of the request. We have custom Rust code that is responsible for validating the bearer auth token. I modeled this Rust verification code from this Go code: https://github.com/firebase/firebase-admin-go/blob/master/auth/token_verifier.go#L156 If you know of a similar Rust implementation that is already written, please let me know.
I have unit tests for the validation function and the unit tests need to accept a JWT auth token that matches what would be created by the Firebase client libraries. In Go, this is done by using the Client.CustomToken function but I don't see a similar way to create a token in Rust which prompted me to create this issue. I looked at the FirestoreDb::with_options_token_source and it wasn't clear to me how to use that function for this use case.

@abdolence
Copy link
Owner

Thanks for the details. Let me contemplate on this a bit. I think this probably will need to update Google Cloud SDK first, since it is not flexible enough right now to provide the required token.

@lukemauldinks
Copy link
Author

Thank you. I have some example Go code I am using along with some Rust validation code that I can provide if needed.
It would be a nice to have if the Rust crate also had an implementation to verify the bearer auth token. I can share my implementation for reference if you want.

@abdolence
Copy link
Owner

I made some draft implementation here #152 so you can provide your own token source (or static token value if you want).
Can you check it with your implementation, please?

Left an example:

  • examples/token_auth.rs

Can you check it with your tokens?

PS In case you're not aware, you can point your code directly at my feature branch on github:
https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html#specifying-dependencies-from-git-repositories

@abdolence abdolence added the enhancement New feature or request label Jan 1, 2024
@lukemauldinks
Copy link
Author

I pulled in the feature branch and I am having a bit of trouble because the function generate_for_scopes was removed. For reference, this is the function I am attempting to convert:

/// Create a bearer token to be used with all Auth requests
pub async fn generate_auth_token(
    token_source_type: TokenSourceType,
    token_scopes: Option<Vec<String>>,
) -> Result<Token, AuthError> {
    Token::generate_for_scopes(
        token_source_type,
        token_scopes.unwrap_or(GCP_DEFAULT_SCOPES.to_owned()),
    )
    .await
    .map_err(|e| AuthError::GoogleError(e.to_string()))
}

@lukemauldinks
Copy link
Author

Also in my code, TokenSourceType was used in a struct that derived Clone and now that is a compile error because TokenSourceType is not cloneable.

@abdolence
Copy link
Owner

Ok, let me return those :) I didn't know that they actually used by anyone.

@lukemauldinks
Copy link
Author

I am going through the Rust example and it is requiring TOKEN_VALUE which I am unsure how to provide. I also still don't know if this will create the token that I need. For reference, below is the Go code. The value that I am wanting Rust to create is the raw token.

package main

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log"
	"net/http"
	"time"

	firebase "firebase.google.com/go/v4"
	"github.com/dgrijalva/jwt-go"
	"google.golang.org/api/option"
)

func main() {
	customToken, err := createTokenCustom()
	if err != nil {
		log.Fatalf("error creating custom token: %v\n", err)
		return
	}
	fmt.Printf("custom token: %s\n", customToken)
	tokenID, err := getTokenId(customToken)
	if err != nil {
		log.Fatalf("error creating id token: %v\n", err)
		return
	}
	err = printToken(tokenID.IdToken)
	if err != nil {
		log.Fatalf("error printing custom token: %v\n", err)
		return
	}
	fmt.Printf("unix time: %v\n", time.Now().Unix())
}

func createTokenCustom() (string, error) {
	opt := option.WithCredentialsFile("XXXX-firebase.json")
	config := &firebase.Config{ProjectID: "XXXX-PROJECT_ID"}
	app, err := firebase.NewApp(context.Background(), config, opt)
	if err != nil {
		return "", err
	}
	client, err := app.Auth(context.Background())
	if err != nil {
		return "", err
	}
	uid := "XXXX-USER_UID" 
	customToken, err := client.CustomToken(context.Background(), uid)
	if err != nil {
		return "", err
	}
	return customToken, nil
}

type SignInRequest struct {
	Token             string `json:"token"`
	ReturnSecureToken bool   `json:"returnSecureToken"`
}
type SignInResponse struct {
	IdToken      string `json:"idToken"`
	RefreshToken string `json:"refreshToken"`
	ExpiresIn    string `json:"expiresIn"`
}

func getTokenId(customToken string) (*SignInResponse, error) {
	url := "https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=XXX-CUSTOM_KEY"

	data := SignInRequest{
		Token:             customToken,
		ReturnSecureToken: true,
	}

	payloadBuf := new(bytes.Buffer)
	json.NewEncoder(payloadBuf).Encode(data)

	req, _ := http.NewRequest("POST", url, payloadBuf)

	req.Header.Add("Content-Type", "application/json")

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		log.Fatalf("An Error Occured %v", err)
	}

	body, _ := io.ReadAll(res.Body)
	var signInResponse SignInResponse
	err = json.Unmarshal(body, &signInResponse)
	if err != nil {
		return nil, err
	}

	return &signInResponse, nil
}

func printToken(tokenString string) error {
	fmt.Printf("raw token: %s\n", tokenString)

	token, _, err := new(jwt.Parser).ParseUnverified(tokenString, jwt.MapClaims{})
	if err != nil {
		return err
	}

	header := token.Header
	for k, v := range header {
		fmt.Printf("header - %v : %v\n", k, v)
	}

	claims, ok := token.Claims.(jwt.MapClaims)
	if !ok {
		return errors.New("Can't parse token claims")
	}

	for k, v := range claims {
		fmt.Printf("claim - %v : %v\n", k, v)
	}
	return nil
}

@abdolence
Copy link
Owner

abdolence commented Jan 1, 2024

signInWithCustomToken is a part of Identitytoolkit v1, which is not provided as gRPC and I think it is not available as Open API spec either.
I remember I did an investigation a while ago: abdolence/gcloud-sdk-rs#84
So, the only option right now just to use REST client (of course PR's are welcome).

Also in my code, TokenSourceType was used in a struct that derived Clone and now that is a compile error because TokenSourceType is not cloneable.

Unfortunately, this can't be cloneable anymore since it accepts Box<> which is not cloneable, so you will need to fix that part of the code on your side.

@lukemauldinks
Copy link
Author

I understand regarding signInWithCustomToken Can the library at least create the custom token value and then I will make a REST API call to perform the signInWithCustomToken functionality?

@abdolence
Copy link
Owner

I've returned back generate_for_scopes in gcloud sdk v0.24.1, so please remove/update your lock file.

Can the library at least create the custom token value

Looking at your links, this part is inside Firebase/Auth-related libraries (outside of Firestore).

Looking at the official documentation though https://firebase.google.com/docs/auth/admin/create-custom-tokens#create_custom_tokens_using_a_third-party_jwt_library
it seems you can create a JWT token yourself by taking some JWT crate for Rust. It looks fairly straightforward.

@lukemauldinks
Copy link
Author

Thank you for returning the generate_for_scopes function.
Referring to the link, that does look relatively straight-forward and I can do that in my library or would you want to add that functionality into either firestore-rs or gcloud-sdk?

@abdolence
Copy link
Owner

It is probably closer to gcloud-sdk. Someone already tried to implement something similar before:
abdolence/gcloud-sdk-rs#76

Also if you are able to provide some kind of implementation of some of the functions for Identitytoolkit v1 would be beneficial for others I think :)

@lukemauldinks
Copy link
Author

I understand. I don't know if I have the time or experience to do a proper implementation of the IdentityToolkit v1. I really wish Google would just provide a properly supported Gcloud SDK for Rust similar to AWS Rust SDK.
Regarding the related PR, my code compiles so you can merge the PR. You might want to update the gcloud-sdk version to 0.24.1.

@abdolence
Copy link
Owner

I agree, I would prefer not to support Google SDKs myself indeed.

Regarding Identity toolkit, just to be clear, I just suggested separating it into a new module available for others so people can add missing functions themselves in one place. There is no need to create full functional since we don't have Open API spec available or protobufs.

@lukemauldinks
Copy link
Author

Could you please provide some clarity for me on the tokens generated by Token::generate_for_scopes vs the custom tokens detailed here: https://firebase.google.com/docs/auth/admin/create-custom-tokens#create_custom_tokens_using_a_third-party_jwt_library

@lukemauldinks
Copy link
Author

For reference for anyone who reads this issue later, below is working Rust code to generate signed Firebase tokens:

use jsonwebtoken::{encode, EncodingKey, Header};
use reqwest::header::CONTENT_TYPE;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};

#[derive(Debug, Serialize, Deserialize)]
pub struct CustomToken {
    iss: String,
    aud: String,
    exp: i64,
    iat: i64,
    sub: Option<String>,
    uid: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    tenant_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    claims: Option<HashMap<String, serde_json::Value>>,
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    // Logging with debug enabled
    let subscriber = tracing_subscriber::fmt()
        .with_env_filter("firestore=debug")
        .finish();
    tracing::subscriber::set_global_default(subscriber)?;

    let firebase_json = read_firebase_json_from_file("./src/XXX-service-key.json")?;
    let uid = "XXX-userid";
    let custom_token = custom_token_with_claims(
        uid,
        &firebase_json.client_email,
        &firebase_json.private_key,
        None,
    )
    .await?;
    println!("custom_token: {}", custom_token);
    print_jwt_token(&custom_token)?;

    let id_token = get_token_id(custom_token).await?;
    println!("id_token: {}", id_token.id_token);
    print_jwt_token(&id_token.id_token)?;

    Ok(())
}
pub async fn custom_token_with_claims(
    uid: &str,
    svc_email: &str,
    rsa_key: &str,
    dev_claims: Option<HashMap<String, serde_json::Value>>,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
    let iss = svc_email.to_string();

    if uid.is_empty() || uid.len() > 128 {
        return Err("uid must be non-empty, and not longer than 128 characters".into());
    }

    let reserved_claims = vec![
        "acr",
        "amr",
        "at_hash",
        "aud",
        "auth_time",
        "azp",
        "cnf",
        "c_hash",
        "exp",
        "firebase",
        "iat",
        "iss",
        "jti",
        "nbf",
        "nonce",
        "sub",
    ];
    let mut disallowed = vec![];

    if let Some(claims) = &dev_claims {
        for k in reserved_claims {
            if claims.contains_key(k) {
                disallowed.push(k.to_string());
            }
        }
    }

    match disallowed.len() {
        1 => {
            return Err(format!(
                "developer claim {} is reserved and cannot be specified",
                disallowed[0]
            )
            .into());
        }
        len if len > 1 => {
            return Err(format!(
                "developer claims {} are reserved and cannot be specified",
                disallowed.join(", ")
            )
            .into());
        }
        _ => {}
    }

    let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as i64;

    let token = CustomToken {
        iss: iss.clone(),
        sub: Some(iss.clone()),
        aud: "https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit".to_string(),
        uid: Some(uid.to_string()),
        iat: now,
        exp: now + 3600, // one hour in seconds
        tenant_id: None,
        //tenant_id: ctx.tenant_id.clone(),
        claims: dev_claims,
    };

    let header = Header::new(jsonwebtoken::Algorithm::RS256);
    let key = EncodingKey::from_rsa_pem(rsa_key.as_bytes())?;
    let token_str = encode(&header, &token, &key)?;

    Ok(token_str)
}

#[derive(Serialize, Deserialize, Debug)]
pub struct FirebaseJson {
    r#type: String,
    project_id: String,
    private_key_id: String,
    private_key: String,
    client_email: String,
}
pub fn read_firebase_json_from_file(
    path: &str,
) -> Result<FirebaseJson, Box<dyn std::error::Error + Send + Sync>> {
    let file_content = fs::read_to_string(path)?;
    let ret: FirebaseJson = serde_json::from_str(&file_content)?;
    Ok(ret)
}

#[derive(Serialize)]
struct SignInRequest {
    token: String,
    #[serde(rename = "returnSecureToken")]
    return_secure_token: bool,
}

#[derive(Deserialize)]
struct SignInResponse {
    #[serde(rename = "idToken")]
    id_token: String,
    #[serde(rename = "refreshToken")]
    refresh_token: String,
    #[serde(rename = "expiresIn")]
    expires_in: String,
}

async fn get_token_id(
    custom_token: String,
) -> Result<SignInResponse, Box<dyn std::error::Error + Send + Sync>> {
    let url = "https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=XXX-key";
    let client = reqwest::Client::new();

    let data = SignInRequest {
        token: custom_token,
        return_secure_token: true,
    };

    let res = client
        .post(url)
        .header(CONTENT_TYPE, "application/json")
        .json(&data)
        .send()
        .await?;

    if res.status().is_success() {
        let sign_in_response: SignInResponse = res.json().await?;
        Ok(sign_in_response)
    } else {
        let body = res.text().await?;
        Err(body.into())
    }
}

fn print_jwt_token(token_string: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    let header = jsonwebtoken::decode_header(token_string)?;

    println!("header: {:?}", header);

    let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256);
    validation.insecure_disable_signature_validation();
    validation.validate_aud = false;
    validation.validate_exp = false;

    let token_data = jsonwebtoken::decode::<serde_json::Value>(
        token_string,
        &jsonwebtoken::DecodingKey::from_secret("secret".as_ref()),
        &validation,
    )?;

    println!("claims: {:?}", token_data.claims);

    Ok(())
}

@lukemauldinks
Copy link
Author

Can you go ahead and merge in the external-token-support branch so that it will update the gcloud-sdk version?

@abdolence
Copy link
Owner

Could you please provide some clarity for me on the tokens generated by Token::generate_for_scopes

Token::generate_for_scopes is basically a few lines wrapper that relies on Token Source implementation so it won't help with this at all. This is why I was surprised to see it is used outside of the crate.

Glad to see that you have a working version now 👍🏻
I have merged the branch already, I will release it later this week.

@abdolence
Copy link
Owner

Let me know if you still have any issues or additional questions. Closing this for now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request question Further information is requested
Projects
None yet
Development

No branches or pull requests

2 participants