google sso - better error types, README with setup instructions (#1565)

This commit is contained in:
Brandon Thomas authored and GitHub committed 2026-05-25 12:59:04 -04:00
1 parent d25b206cae
commit 06cc380d74
12 files changed
+150 -105

No files matched your search

Generated
-1
View File
@@ -4865,7 +4865,6 @@ version = "0.0.1"
dependencies = [
"base64 0.13.1",
"coarsetime",
"errors",
"jwt-simple",
"reqwest 0.12.23",
"serde",
@@ -21,7 +21,6 @@ path = "src/lib.rs"
workspace-build-acceleration.workspace = true
# Internal
errors.workspace = true
# External
@@ -0,0 +1,16 @@
# Google Sign In
https://developers.google.com/identity/sign-in/web/sign-in
This has a link to the management console.
## Frontend Setup
```html
<!-- Load Library -->
<script src="https://apis.google.com/js/platform.js" async defer></script>
<!-- Specify Client ID-->
<meta name="google-signin-client_id" content="YOUR_CLIENT_ID.apps.googleusercontent.com">
```
@@ -1,25 +1,30 @@
use crate::certs::jwk_to_public_key::jwk_to_public_key;
use crate::certs::key_map::KeyMap;
use errors::{anyhow, AnyhowResult};
use crate::error::google_sign_in_error::GoogleSignInError;
/// https://developers.google.com/identity/gsi/web/guides/verify-google-id-token
/// "These keys are regularly rotated; examine the Cache-Control header in
/// the response to determine when you should retrieve them again."
const GOOGLE_CERTS_URL : &str = "https://www.googleapis.com/oauth2/v3/certs";
pub async fn download_cert_key_set() -> AnyhowResult<KeyMap> {
pub async fn download_cert_key_set() -> Result<KeyMap, GoogleSignInError> {
let certs = download_certs().await?;
let key_map = jwk_to_public_key(&certs)?;
Ok(key_map)
}
pub async fn download_certs() -> AnyhowResult<String> {
let response = reqwest::get(GOOGLE_CERTS_URL).await?;
pub async fn download_certs() -> Result<String, GoogleSignInError> {
let response = reqwest::get(GOOGLE_CERTS_URL)
.await
.map_err(GoogleSignInError::CertDownloadHttpError)?;
if !response.status().is_success() {
return Err(anyhow!("Failed to download Google certs: {:?}", response.status()));
return Err(GoogleSignInError::CertDownloadBadStatus(response.status()));
}
let body = response.text().await?;
let body = response.text()
.await
.map_err(GoogleSignInError::CertDownloadHttpError)?;
Ok(body)
}
@@ -1,5 +1,5 @@
use crate::certs::key_map::KeyMap;
use errors::{anyhow, AnyhowResult};
use crate::error::google_sign_in_error::GoogleSignInError;
use jwt_simple::prelude::RS256PublicKey;
use serde_derive::Deserialize;
@@ -7,10 +7,11 @@ use serde_derive::Deserialize;
/// Grab Google's keys from here: https://www.googleapis.com/oauth2/v3/certs
/// These are updated frequently throughout the day and must be re-fetched.
/// NB: This implementation is adapted from the crate `signin`.
pub fn jwk_to_public_key(json_web_key: &str) -> AnyhowResult<KeyMap> {
pub fn jwk_to_public_key(json_web_key: &str) -> Result<KeyMap, GoogleSignInError> {
let mut key_map = KeyMap::new();
let json_keys: JsonKeys = serde_json::from_str(json_web_key)?;
let json_keys: JsonKeys = serde_json::from_str(json_web_key)
.map_err(GoogleSignInError::JwkJsonParseFailed)?;
for key in json_keys.keys {
if key.use_ != "sig" {
@@ -18,11 +19,14 @@ pub fn jwk_to_public_key(json_web_key: &str) -> AnyhowResult<KeyMap> {
}
match key.alg.as_ref() {
"RS256" => {
let n_decoded = base64_decode_url(&key.n)?;
let e_decoded = base64_decode_url(&key.e)?;
let n_decoded = base64_decode_url(&key.n)
.map_err(GoogleSignInError::JwkBase64DecodeFailed)?;
let e_decoded = base64_decode_url(&key.e)
.map_err(GoogleSignInError::JwkBase64DecodeFailed)?;
let public_key = RS256PublicKey::from_components(&n_decoded, &e_decoded)?
.with_key_id(&key.kid);
let public_key = RS256PublicKey::from_components(&n_decoded, &e_decoded)
.map_err(|err| GoogleSignInError::JwkRsaKeyConstructionFailed(format!("{}", err)))?
.with_key_id(&key.kid);
key_map.insert(key.kid, public_key);
}
@@ -31,7 +35,7 @@ pub fn jwk_to_public_key(json_web_key: &str) -> AnyhowResult<KeyMap> {
}
if key_map.is_empty() {
return Err(anyhow!("No keys found in JWK"));
return Err(GoogleSignInError::JwkNoKeysFound);
}
Ok(key_map)
@@ -1,5 +1,5 @@
use crate::claims::google_custom_claims::GoogleCustomClaims;
use errors::{anyhow, AnyhowResult};
use crate::error::google_sign_in_error::GoogleSignInError;
use jwt_simple::claims::{Audiences, JWTClaims};
pub struct Claims {
@@ -37,11 +37,11 @@ impl Claims {
/// Determine if the claim audience is as expected.
/// This is necessary so third parties don't send claims signed on their behalf by Google.
pub fn audience_matches(&self, audience: &str) -> AnyhowResult<bool> {
pub fn audience_matches(&self, audience: &str) -> Result<bool, GoogleSignInError> {
match self.claims.audiences.as_ref() {
Some(Audiences::AsString(claim_audience)) => Ok(claim_audience.eq(audience)),
Some(Audiences::AsSet(audiences)) => Ok(audiences.contains(audience)),
_ => Err(anyhow!("Invalid audience type")),
_ => Err(GoogleSignInError::AudienceMissing),
}
}
@@ -1,9 +1,8 @@
use crate::certs::key_map::KeyMap;
use crate::claims::claims::Claims;
use crate::claims::google_custom_claims::GoogleCustomClaims;
use crate::errors::VerifyError;
use crate::error::google_sign_in_error::GoogleSignInError;
use crate::jwt::decode_jwt_header::decode_jwt_header;
use errors::anyhow;
use jwt_simple::algorithms::RS256PublicKey;
use jwt_simple::algorithms::RSAPublicKeyLike;
use jwt_simple::common::VerificationOptions;
@@ -12,16 +11,15 @@ use std::collections::HashSet;
/// Decode a Google Sign In JWT.
/// Verification options can be supplied to increase clock skew tolerance, etc.
pub fn decode_and_verify_token_claims(keys: &KeyMap, token: &str, mut options: Option<VerificationOptions>) -> Result<Claims, VerifyError> {
let header = decode_jwt_header(token)
.map_err(|err| VerifyError::JwtDecodeError { source: err })?;
pub fn decode_and_verify_token_claims(keys: &KeyMap, token: &str, mut options: Option<VerificationOptions>) -> Result<Claims, GoogleSignInError> {
let header = decode_jwt_header(token)?;
let key_id = header.kid.as_deref()
.or_else(|| keys.keys().next().map(|k|k.as_str()))
.ok_or_else(|| anyhow!("No key ID found"))?;
.ok_or(GoogleSignInError::JwtNoKeyId)?;
let key = keys.get(key_id)
.ok_or_else(|| VerifyError::JwtKeyMissing { requested_key: key_id.to_string() })?;
.ok_or_else(|| GoogleSignInError::JwtKeyMissing { requested_key: key_id.to_string() })?;
if options.is_none() {
options = Some(VerificationOptions {
@@ -36,7 +34,7 @@ pub fn decode_and_verify_token_claims(keys: &KeyMap, token: &str, mut options: O
decode_and_verify_token_claims_with_key(key, token, options)
}
pub fn decode_and_verify_token_claims_with_key(key: &RS256PublicKey, token: &str, options: Option<VerificationOptions>) -> Result<Claims, VerifyError> {
pub fn decode_and_verify_token_claims_with_key(key: &RS256PublicKey, token: &str, options: Option<VerificationOptions>) -> Result<Claims, GoogleSignInError> {
let result = key.verify_token::<GoogleCustomClaims>(token, options);
let claims = match result {
@@ -44,22 +42,22 @@ pub fn decode_and_verify_token_claims_with_key(key: &RS256PublicKey, token: &str
Err(err) => {
if let Some(inner_error) = err.downcast_ref::<JWTError>() {
match inner_error {
JWTError::RequiredAudienceMismatch => return Err(VerifyError::JwtInvalidAudience),
JWTError::RequiredAudienceMissing => return Err(VerifyError::JwtInvalidAudience),
JWTError::RequiredIssuerMismatch => return Err(VerifyError::JwtInvalidIssuer),
JWTError::RequiredIssuerMissing => return Err(VerifyError::JwtInvalidIssuer),
JWTError::TokenHasExpired => return Err(VerifyError::JwtExpired),
JWTError::RequiredAudienceMismatch => return Err(GoogleSignInError::JwtInvalidAudience),
JWTError::RequiredAudienceMissing => return Err(GoogleSignInError::JwtInvalidAudience),
JWTError::RequiredIssuerMismatch => return Err(GoogleSignInError::JwtInvalidIssuer),
JWTError::RequiredIssuerMissing => return Err(GoogleSignInError::JwtInvalidIssuer),
JWTError::TokenHasExpired => return Err(GoogleSignInError::JwtExpired),
_ => {}, // Fall-through
}
}
return Err(VerifyError::AnyhowError(err));
return Err(GoogleSignInError::JwtVerifyFailed(format!("{}", err)));
}
};
match claims.issuer.as_deref() {
Some("https://accounts.google.com" | "accounts.google.com") => {} // Permitted
_ => {
return Err(VerifyError::JwtInvalidIssuer);
return Err(GoogleSignInError::JwtInvalidIssuer);
}
}
@@ -72,7 +70,7 @@ pub fn decode_and_verify_token_claims_with_key(key: &RS256PublicKey, token: &str
mod tests {
use crate::certs::jwk_to_public_key::jwk_to_public_key;
use crate::decode_and_verify_token_claims::decode_and_verify_token_claims;
use crate::errors::VerifyError;
use crate::error::google_sign_in_error::GoogleSignInError;
use coarsetime::Duration;
use jwt_simple::claims::Audiences;
use jwt_simple::prelude::VerificationOptions;
@@ -180,7 +178,7 @@ mod tests {
};
match decode_and_verify_token_claims(&key_map, credential, Some(options)) {
Err(VerifyError::JwtKeyMissing { requested_key}) => {
Err(GoogleSignInError::JwtKeyMissing { requested_key}) => {
assert_eq!(requested_key, "b2620d5e7f132b52afe8875cdf3776c064249d04");
}
Err(err) => {
@@ -201,7 +199,7 @@ mod tests {
let credential = "eyJhbGciOiJSUzI1NiIsImtpZCI6ImIyNjIwZDVlN2YxMzJiNTJhZmU4ODc1Y2RmMzc3NmMwNjQyNDlkMDQiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiI3ODg4NDMwMzQyMzctdXFjZzh0YmdvZnJjZjF0bzM3ZTFicXBoZDkyNGphZjYuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJhdWQiOiI3ODg4NDMwMzQyMzctdXFjZzh0YmdvZnJjZjF0bzM3ZTFicXBoZDkyNGphZjYuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJzdWIiOiIxMTMxMDE5Njc2MTIzOTY3OTM3NzciLCJlbWFpbCI6InZvY29kZXMyMDIwQGdtYWlsLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJuYmYiOjE3MjY3ODYxMDAsIm5hbWUiOiJWb2NvZGVzIFZvY29kZXMiLCJwaWN0dXJlIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EvQUNnOG9jTHoyLTJPYUFtME1ReFI2ajhDTnItUG84X1hyLWFyeUFUaUNuNGMwaV9UdURtTF9nPXM5Ni1jIiwiZ2l2ZW5fbmFtZSI6IlZvY29kZXMiLCJmYW1pbHlfbmFtZSI6IlZvY29kZXMiLCJpYXQiOjE3MjY3ODY0MDAsImV4cCI6MTcyNjc5MDAwMCwianRpIjoiNGQ0NGVlYWMwNmNlNzlmYzBhYjIyNzBjZmVlYTMwZDhhY2Y3NzYxMyJ9.EYg71yIkvhxFGc8ZVCXeTOAmPAtLYDphHnkdf1sh8b_Jz4Y7S1DpmiTqQ1ytxu7J1xNixvdwhuIDzSlCvlxaFl8475GvAlyPTNtZtmWbFD5SRM_XHLOynijOp8WQ4nej-CHvT1KjjqMfkZ1EeQMoWk1H72PxPg_RiUgzsklkUs1wOkLAySk7R3EIAl7bIzpoY_WH2pxv9ccFpBtKDHaDqHkxAWBUQX0-G7ZXZBPVz07V28ZfdbzFDapjZaUFbumazh_-J2-9AA6JkcteF4h_gpbBcLYAuxt5bWI5FECWbYe42khwb93WJ5SK12Tt0EPoyzIObJs14NWGAajtHTg3wA";
match decode_and_verify_token_claims(&key_map, credential, None) {
Err(VerifyError::JwtExpired) => {
Err(GoogleSignInError::JwtExpired) => {
// Expected error case
}
Err(err) => {
@@ -231,7 +229,7 @@ mod tests {
};
match decode_and_verify_token_claims(&key_map, credential, Some(options)) {
Err(VerifyError::JwtInvalidIssuer) => {
Err(GoogleSignInError::JwtInvalidIssuer) => {
// Expected error case
}
Err(err) => {
@@ -261,7 +259,7 @@ mod tests {
};
match decode_and_verify_token_claims(&key_map, credential, Some(options)) {
Err(VerifyError::JwtInvalidAudience) => {
Err(GoogleSignInError::JwtInvalidAudience) => {
// Expected error case
}
Err(err) => {
@@ -0,0 +1,80 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
/// All failure modes surfaced by the `google_sign_in` crate.
///
/// Kept as a single flat enum on purpose — the crate is small enough that
/// splitting into client/api/specific tiers (like beeble_client / gmicloud_client
/// do) is overkill here.
#[derive(Debug)]
pub enum GoogleSignInError {
// ── Cert download (Google's JWK endpoint) ──
/// HTTP error talking to Google's JWK cert endpoint.
CertDownloadHttpError(reqwest::Error),
/// Google's cert endpoint returned a non-2xx status.
CertDownloadBadStatus(reqwest::StatusCode),
// ── JWK parse ──
/// Failed to parse Google's JWK payload as JSON.
JwkJsonParseFailed(serde_json::Error),
/// Failed to base64-decode a JWK key component (`n` or `e`).
JwkBase64DecodeFailed(base64::DecodeError),
/// Failed to construct an RSA public key from JWK components.
/// The wrapped string is `jwt_simple`'s formatted error.
JwkRsaKeyConstructionFailed(String),
/// JWK payload contained no usable signing keys.
JwkNoKeysFound,
// ── JWT header decode ──
/// JWT was malformed — couldn't split off the header segment.
JwtMalformed,
/// Failed to base64-decode the JWT header segment.
/// The wrapped string is `ct_codecs`'s formatted error.
JwtHeaderBase64DecodeFailed(String),
/// Failed to parse the JWT header as JSON.
JwtHeaderJsonParseFailed(serde_json::Error),
// ── JWT claim verification ──
/// JWT carried no `kid` and the keymap is empty — no key to verify with.
JwtNoKeyId,
/// JWT requested a `kid` not present in the JWK keymap.
/// Refreshing the JWK keymap may resolve.
JwtKeyMissing { requested_key: String },
/// JWT has expired (was issued before the current clock + skew tolerance).
JwtExpired,
/// JWT audience claim is absent or doesn't match the configured allowlist.
JwtInvalidAudience,
/// JWT issuer claim is absent or doesn't match the configured allowlist.
JwtInvalidIssuer,
/// JWT signature verification failed for some other reason.
/// The wrapped string is `jwt_simple`'s formatted error.
JwtVerifyFailed(String),
// ── Claims helpers ──
/// `audience_matches()` was called on claims that have no audience field.
AudienceMissing,
}
impl Error for GoogleSignInError {}
impl Display for GoogleSignInError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self)
}
}
@@ -0,0 +1 @@
pub mod google_sign_in_error;
@@ -1,59 +0,0 @@
use errors::AnyhowError;
use std::error::Error;
use std::fmt::Display;
#[derive(Debug)]
pub enum VerifyError {
/// Could not decode a JWT token.
JwtDecodeError { source: AnyhowError },
/// The JWT has expired (was issued before the current
/// clock + deadline + skew tolerance)
JwtExpired,
/// The key requested by the JWT was not available.
/// This likely means we need to refresh our JWK key set.
JwtKeyMissing { requested_key: String },
/// The audience for the JWT claims was absent or unexpected.
JwtInvalidAudience,
/// The issuer of the JWT claims was absent or unexpected.
JwtInvalidIssuer,
/// Any other error type we haven't wrapped.
AnyhowError(AnyhowError),
}
impl Error for VerifyError {}
impl Display for VerifyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::JwtDecodeError { source } => {
write!(f, "JWT decode error: {}", source)
}
Self::JwtExpired => {
write!(f, "JWT token has expired")
}
Self::JwtKeyMissing { requested_key } => {
write!(f, "JWT requested key absent from JWK: {}", requested_key)
}
Self::JwtInvalidAudience => {
write!(f, "Unexpected or missing JWT audience")
}
Self::JwtInvalidIssuer => {
write!(f, "Unexpected or missing JWT issuer")
}
Self::AnyhowError(err) => {
write!(f, "AnyhowError: {}", err)
}
}
}
}
impl From<AnyhowError> for VerifyError {
fn from(value: AnyhowError) -> Self {
Self::AnyhowError(value)
}
}
@@ -1,4 +1,4 @@
use errors::{anyhow, AnyhowResult};
use crate::error::google_sign_in_error::GoogleSignInError;
use jwt_simple::prelude::Base64UrlSafeNoPadding;
use jwt_simple::reexports::ct_codecs::Decoder;
use serde_derive::Deserialize;
@@ -10,13 +10,15 @@ pub(crate) struct JwtHeader {
// Other fields absent
}
pub fn decode_jwt_header(jwt: &str) -> AnyhowResult<JwtHeader> {
pub fn decode_jwt_header(jwt: &str) -> Result<JwtHeader, GoogleSignInError> {
let mut parts = jwt.split('.');
let jwt_header_b64 = parts.next().ok_or(anyhow!("could not split jwt"))?;
let jwt_header_b64 = parts.next().ok_or(GoogleSignInError::JwtMalformed)?;
let jwt_header: JwtHeader = serde_json::from_slice(
&Base64UrlSafeNoPadding::decode_to_vec(jwt_header_b64, None)?,
)?;
let decoded = Base64UrlSafeNoPadding::decode_to_vec(jwt_header_b64, None)
.map_err(|err| GoogleSignInError::JwtHeaderBase64DecodeFailed(format!("{}", err)))?;
let jwt_header: JwtHeader = serde_json::from_slice(&decoded)
.map_err(GoogleSignInError::JwtHeaderJsonParseFailed)?;
Ok(jwt_header)
}
+1 -1
View File
@@ -21,7 +21,7 @@
pub mod certs;
pub mod claims;
pub mod decode_and_verify_token_claims;
pub mod errors;
pub mod error;
pub(crate) mod jwt;