who_need_help/test/who_need_help/google_auth_test.exs

418 lines
13 KiB
Elixir

defmodule WhoNeedHelp.GoogleAuthTest do
use ExUnit.Case, async: false
alias WhoNeedHelp.GoogleAuth
alias WhoNeedHelp.GoogleAuth.AssentAdapter
defmodule GoogleJwksHTTPAdapter do
@behaviour Assent.HTTPAdapter
alias Assent.HTTPAdapter.HTTPResponse
@impl true
def request(:get, "https://accounts.google.test/keys", nil, _headers, options) do
{:ok,
%HTTPResponse{
status: 200,
headers: [{"content-type", "application/json"}],
body: Jason.encode!(%{"keys" => Keyword.fetch!(options, :jwks)})
}}
end
def request(_method, _url, _body, _headers, _options),
do: {:error, :unexpected_google_test_request}
end
test "Google authorization uses OIDC state, nonce, PKCE, and identity-only scopes" do
nonce = "session-bound-nonce"
assert {:ok, %{url: url, session_params: session_params}} =
Assent.Strategy.Google.authorize_url(
client_id: "client",
client_secret: "secret",
redirect_uri: "https://example.test/auth/google/callback",
nonce: nonce,
code_verifier: true,
openid_configuration: %{
"authorization_endpoint" => "https://accounts.google.test/o/oauth2/v2/auth"
}
)
uri = URI.parse(url)
params = URI.decode_query(uri.query)
assert uri.host == "accounts.google.test"
assert params["redirect_uri"] == "https://example.test/auth/google/callback"
assert params["scope"] == "openid email profile"
assert params["state"] == session_params.state
assert params["nonce"] == nonce
assert session_params.nonce == nonce
assert params["code_challenge_method"] == "S256"
assert is_binary(session_params.code_verifier)
refute Map.has_key?(params, "access_type")
end
test "normalizes only a verified Google identity and discards token-shaped claims" do
assert {:ok, identity} =
AssentAdapter.normalize_identity(%{
"sub" => "google-subject-123",
"email" => " Alice@Example.COM ",
"email_verified" => true,
"name" => "Alice Neighbor",
"access_token" => "must-not-leak",
"id_token" => "must-not-leak"
})
assert identity == %{
provider_uid: "google-subject-123",
email: "alice@example.com",
email_verified: true,
display_name: "Alice Neighbor",
hosted_domain: nil
}
refute Map.has_key?(identity, :access_token)
refute Map.has_key?(identity, :id_token)
end
test "normalizes the hosted-domain claim and applies Google's authoritative-email rules" do
assert {:ok, workspace_identity} =
AssentAdapter.normalize_identity(%{
"sub" => "workspace-subject",
"email" => "Owner@Example.ORG",
"email_verified" => true,
"name" => "Workspace Owner",
"hd" => " Example.ORG "
})
assert workspace_identity.hosted_domain == "example.org"
assert GoogleAuth.authoritative_email?(workspace_identity)
assert GoogleAuth.authoritative_email?(%{
email: "OWNER@GMAIL.COM",
email_verified: true,
hosted_domain: nil
})
refute GoogleAuth.authoritative_email?(%{
email: "owner@example.org",
email_verified: true,
hosted_domain: nil
})
refute GoogleAuth.authoritative_email?(%{
email: "owner@gmail.com",
email_verified: false,
hosted_domain: "gmail.com"
})
end
test "rejects an unverified or incomplete Google email" do
assert {:error, :email_not_verified} =
AssentAdapter.normalize_identity(%{
"sub" => "subject",
"email" => "alice@example.com",
"email_verified" => false
})
assert {:error, :invalid_provider_identity} =
AssentAdapter.normalize_identity(%{
"sub" => "subject",
"email_verified" => true
})
end
test "native ID token verification checks signature, audience, expiry, and nonce" do
client_id = "native-client.apps.googleusercontent.com"
client_secret = "native-test-signing-secret-with-sufficient-length"
nonce = "one-time-native-nonce"
now = System.system_time(:second)
original = Application.get_env(:who_need_help, :google_auth)
on_exit(fn ->
if is_nil(original) do
Application.delete_env(:who_need_help, :google_auth)
else
Application.put_env(:who_need_help, :google_auth, original)
end
end)
Application.put_env(
:who_need_help,
:google_auth,
client_id: client_id,
client_secret: client_secret,
id_token_signed_response_alg: "HS256",
openid_configuration: %{"issuer" => "https://accounts.google.com"}
)
token =
signed_id_token(client_secret, %{
"iss" => "https://accounts.google.com",
"sub" => "native-subject",
"aud" => client_id,
"iat" => now,
"exp" => now + 300,
"nonce" => nonce,
"email" => "Native@Example.COM",
"email_verified" => true,
"name" => "Native Neighbor"
})
assert {:ok,
%{
provider_uid: "native-subject",
email: "native@example.com",
email_verified: true,
display_name: "Native Neighbor",
hosted_domain: nil
}} = AssentAdapter.verify_id_token(token, nonce)
assert {:error, _reason} = AssentAdapter.verify_id_token(token, "different-nonce")
wrong_audience =
signed_id_token(client_secret, %{
"iss" => "https://accounts.google.com",
"sub" => "native-subject",
"aud" => "another-client.apps.googleusercontent.com",
"iat" => now,
"exp" => now + 300,
"nonce" => nonce,
"email" => "native@example.com",
"email_verified" => true
})
assert {:error, _reason} = AssentAdapter.verify_id_token(wrong_audience, nonce)
expired =
signed_id_token(client_secret, %{
"iss" => "https://accounts.google.com",
"sub" => "native-subject",
"aud" => client_id,
"iat" => now - 600,
"exp" => now - 300,
"nonce" => nonce,
"email" => "native@example.com",
"email_verified" => true
})
assert {:error, _reason} = AssentAdapter.verify_id_token(expired, nonce)
end
test "native ID token accepts only an allowlisted Android authorized party" do
web_client_id = "web-client.apps.googleusercontent.com"
android_client_id = "android-client.apps.googleusercontent.com"
client_secret = "native-test-signing-secret-with-sufficient-length"
nonce = "one-time-cross-client-nonce"
now = System.system_time(:second)
restore_google_auth_config()
Application.put_env(
:who_need_help,
:google_auth,
client_id: web_client_id,
client_secret: client_secret,
authorized_party_ids: [android_client_id],
id_token_signed_response_alg: "HS256",
openid_configuration: %{"issuer" => "https://accounts.google.com"}
)
claims = %{
"iss" => "https://accounts.google.com",
"sub" => "cross-client-subject",
"aud" => web_client_id,
"azp" => android_client_id,
"iat" => now,
"exp" => now + 300,
"nonce" => nonce,
"email" => "CrossClient@Example.COM",
"email_verified" => true,
"name" => "Cross Client"
}
token = signed_id_token(client_secret, claims)
assert {:ok,
%{
provider_uid: "cross-client-subject",
email: "crossclient@example.com",
email_verified: true,
display_name: "Cross Client",
hosted_domain: nil
}} = AssentAdapter.verify_id_token(token, nonce)
unauthorized_token =
signed_id_token(client_secret, %{claims | "azp" => "other.apps.googleusercontent.com"})
assert {:error, _reason} = AssentAdapter.verify_id_token(unauthorized_token, nonce)
assert {:error, _reason} =
AssentAdapter.verify_id_token(token, "different-cross-client-nonce")
expired_token =
signed_id_token(client_secret, %{claims | "iat" => now - 600, "exp" => now - 300})
assert {:error, _reason} = AssentAdapter.verify_id_token(expired_token, nonce)
future_token = signed_id_token(client_secret, %{claims | "iat" => now + 300})
assert {:error, _reason} = AssentAdapter.verify_id_token(future_token, nonce)
invalid_signature =
signed_id_token("a-different-signing-secret-with-sufficient-length", claims)
assert {:error, _reason} = AssentAdapter.verify_id_token(invalid_signature, nonce)
end
test "native ID token does not bypass ordinary issuer, audience, or algorithm checks" do
web_client_id = "web-client.apps.googleusercontent.com"
android_client_id = "android-client.apps.googleusercontent.com"
client_secret = "native-test-signing-secret-with-sufficient-length"
nonce = "cross-client-negative-nonce"
now = System.system_time(:second)
restore_google_auth_config()
Application.put_env(
:who_need_help,
:google_auth,
client_id: web_client_id,
client_secret: client_secret,
authorized_party_ids: [android_client_id],
id_token_signed_response_alg: "HS256",
openid_configuration: %{"issuer" => "https://accounts.google.com"}
)
valid_claims = %{
"iss" => "https://accounts.google.com",
"sub" => "cross-client-subject",
"aud" => web_client_id,
"azp" => android_client_id,
"iat" => now,
"exp" => now + 300,
"nonce" => nonce,
"email" => "crossclient@example.com",
"email_verified" => true
}
wrong_issuer =
signed_id_token(client_secret, %{valid_claims | "iss" => "https://issuer.invalid"})
assert {:error, _reason} = AssentAdapter.verify_id_token(wrong_issuer, nonce)
wrong_audience =
signed_id_token(client_secret, %{
valid_claims
| "aud" => "other-web.apps.googleusercontent.com"
})
assert {:error, _reason} = AssentAdapter.verify_id_token(wrong_audience, nonce)
wrong_algorithm =
"a-different-signing-secret-with-sufficient-length"
|> signed_id_token_with_algorithm("HS384", valid_claims)
assert {:error, _reason} = AssentAdapter.verify_id_token(wrong_algorithm, nonce)
end
test "native cross-client verification validates an RS256 signature with the selected JWK" do
web_client_id = "web-client.apps.googleusercontent.com"
android_client_id = "android-client.apps.googleusercontent.com"
nonce = "cross-client-rs256-nonce"
now = System.system_time(:second)
private_jwk = JOSE.JWK.generate_key({:rsa, 2048})
public_jwk =
private_jwk
|> JOSE.JWK.to_public()
|> JOSE.JWK.to_map()
|> elem(1)
|> Map.put("kid", "google-test-key")
restore_google_auth_config()
Application.put_env(
:who_need_help,
:google_auth,
client_id: web_client_id,
client_secret: "unused-by-rs256-verification",
authorized_party_ids: [android_client_id],
http_adapter: {GoogleJwksHTTPAdapter, jwks: [public_jwk]},
openid_configuration: %{
"issuer" => "https://accounts.google.com",
"jwks_uri" => "https://accounts.google.test/keys"
}
)
token =
private_jwk
|> JOSE.JWT.sign(
%{"alg" => "RS256", "kid" => "google-test-key"},
%{
"iss" => "https://accounts.google.com",
"sub" => "cross-client-rs256-subject",
"aud" => web_client_id,
"azp" => android_client_id,
"iat" => now,
"exp" => now + 300,
"nonce" => nonce,
"email" => "rs256@example.com",
"email_verified" => true
}
)
|> JOSE.JWS.compact()
|> elem(1)
assert {:ok, %{provider_uid: "cross-client-rs256-subject"}} =
AssentAdapter.verify_id_token(token, nonce)
wrong_private_jwk = JOSE.JWK.generate_key({:rsa, 2048})
invalid_signature =
wrong_private_jwk
|> JOSE.JWT.sign(
%{"alg" => "RS256", "kid" => "google-test-key"},
%{
"iss" => "https://accounts.google.com",
"sub" => "cross-client-rs256-subject",
"aud" => web_client_id,
"azp" => android_client_id,
"iat" => now,
"exp" => now + 300,
"nonce" => nonce,
"email" => "rs256@example.com",
"email_verified" => true
}
)
|> JOSE.JWS.compact()
|> elem(1)
assert {:error, _reason} = AssentAdapter.verify_id_token(invalid_signature, nonce)
end
defp restore_google_auth_config do
original = Application.get_env(:who_need_help, :google_auth)
on_exit(fn ->
if is_nil(original) do
Application.delete_env(:who_need_help, :google_auth)
else
Application.put_env(:who_need_help, :google_auth, original)
end
end)
end
defp signed_id_token(secret, claims) do
signed_id_token_with_algorithm(secret, "HS256", claims)
end
defp signed_id_token_with_algorithm(secret, algorithm, claims) do
secret
|> JOSE.JWK.from_oct()
|> JOSE.JWT.sign(%{"alg" => algorithm}, claims)
|> JOSE.JWS.compact()
|> elem(1)
end
end