418 lines
13 KiB
Elixir
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
|