diff --git a/.env.example b/.env.example index b645e3c..b93ff00 100644 --- a/.env.example +++ b/.env.example @@ -140,6 +140,9 @@ GITHUB_OAUTH_HTTP_RECEIVE_TIMEOUT_MS= # https://YOUR_PHX_HOST/auth/google/callback GOOGLE_OAUTH_CLIENT_ID= GOOGLE_OAUTH_CLIENT_SECRET= +# Comma-separated Android OAuth client IDs allowed as the verified azp claim +# for Credential Manager cross-client ID tokens. Keep environments isolated. +GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS= # Leave endpoint and timeout overrides empty for Google's discovery endpoint # and Req defaults. The base URL override exists for isolated protocol tests. GOOGLE_OAUTH_BASE_URL= diff --git a/README.md b/README.md index cf4c127..84ac64a 100644 --- a/README.md +++ b/README.md @@ -346,6 +346,12 @@ If both values are empty, the Google buttons remain visible but disabled with an explanation. A partial pair is rejected at startup and by the production environment validator. +For Android Credential Manager, set `GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS` to the +comma-separated OAuth client IDs registered for the Android package/signing +certificates in that same environment. Phoenix still requires the Web client +ID as the token audience; the Android client ID is accepted only as the +verified `azp` (authorized party) claim. + The flow requests `openid email profile`, verifies the provider email claim, uses state, nonce, and PKCE, and discards provider tokens. A new Google identity continues to a safe registration-completion page and creates a confirmed local @@ -359,8 +365,9 @@ isolated protocol drill. The Android app does not open Google OAuth inside the WebView. Its visible Google button uses Android Credential Manager, asks this same server for a session-bound one-time nonce, and sends the resulting ID token directly back to -the server. The server verifies the signature, issuer, audience, expiration, -verified email, and nonce before it reuses the ordinary login, registration, or +the server. The server verifies the signature, algorithm, issuer, Web audience, +allowlisted Android authorized party, expiration, issued-at time, verified +email, and nonce before it reuses the ordinary login, registration, or account-linking rules. `GOOGLE_OAUTH_CLIENT_SECRET` remains server-only; neither it nor the ID token is exposed to WebView JavaScript or compiled into the APK. diff --git a/compose.yaml b/compose.yaml index c15cfe9..cd9165e 100644 --- a/compose.yaml +++ b/compose.yaml @@ -41,6 +41,7 @@ x-app-environment: &app-environment GITHUB_OAUTH_HTTP_RECEIVE_TIMEOUT_MS: ${GITHUB_OAUTH_HTTP_RECEIVE_TIMEOUT_MS:-} GOOGLE_OAUTH_CLIENT_ID: ${GOOGLE_OAUTH_CLIENT_ID:-} GOOGLE_OAUTH_CLIENT_SECRET: ${GOOGLE_OAUTH_CLIENT_SECRET:-} + GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS: ${GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS:-} GOOGLE_OAUTH_BASE_URL: ${GOOGLE_OAUTH_BASE_URL:-} GOOGLE_OAUTH_HTTP_CONNECT_TIMEOUT_MS: ${GOOGLE_OAUTH_HTTP_CONNECT_TIMEOUT_MS:-} GOOGLE_OAUTH_HTTP_RECEIVE_TIMEOUT_MS: ${GOOGLE_OAUTH_HTTP_RECEIVE_TIMEOUT_MS:-} diff --git a/config/runtime.exs b/config/runtime.exs index 7939907..30aabb8 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -203,6 +203,12 @@ config :who_need_help, :social_oauth, github_oauth [ client_id: client_id, client_secret: client_secret, + authorized_party_ids: + System.get_env("GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS", "") + |> String.split(",", trim: true) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> Enum.uniq(), base_url: base_url, authorization_params: [scope: "email profile"], http_adapter: {Assent.HTTPAdapter.Req, oauth_http_options.("GOOGLE")} diff --git a/docs/operations.md b/docs/operations.md index 8751bc2..b175c89 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -183,7 +183,7 @@ test checkout. The generated file uses the ordinary runtime names: | Capability | Values kept in that checkout's `.env` | | --- | --- | -| Google sign-in | `GOOGLE_OAUTH_CLIENT_ID`, `GOOGLE_OAUTH_CLIENT_SECRET` | +| Google sign-in | `GOOGLE_OAUTH_CLIENT_ID`, `GOOGLE_OAUTH_CLIENT_SECRET`, `GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS` | | Browser push | three `WEB_PUSH_VAPID_*` values | | Android Firebase client | four public `WNH_FIREBASE_*` values | | Android delivery | `FCM_PROJECT_ID` and one private service-account source | @@ -205,6 +205,9 @@ for development, `org.whoneedhelp.mobile.staging` plus the stable staging certificate for test, and `org.whoneedhelp.mobile` plus the Play-distributed certificate for production. Do not add a second Google secret file or Gradle property. +Set `GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS` to the comma-separated Android OAuth +client IDs from that environment. They are accepted only as the signed `azp` +claim; the signed `aud` must still contain the environment's Web client ID. Check an environment without printing its secret values: @@ -261,6 +264,7 @@ cd /srv/who_need_help-test TEST_CODEX_SESSION_ID=YOUR_MAIN_CODEX_SESSION_ID \ TEST_GOOGLE_OAUTH_CLIENT_ID=YOUR_TEST_CLIENT_ID \ TEST_GOOGLE_OAUTH_CLIENT_SECRET=YOUR_TEST_CLIENT_SECRET \ +TEST_GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS=YOUR_TEST_ANDROID_CLIENT_ID \ ./scripts/init-test-env.sh test.whoneedhelp.com ./scripts/validate-test-env.sh .env test.whoneedhelp.com ./scripts/deploy-up.sh .env @@ -293,6 +297,7 @@ PRODUCTION_SMTP_USERNAME=YOUR_PRODUCTION_SMTP_LOGIN \ PRODUCTION_SMTP_PASSWORD=YOUR_PRODUCTION_SMTP_PASSWORD \ PRODUCTION_GOOGLE_OAUTH_CLIENT_ID=YOUR_PRODUCTION_CLIENT_ID \ PRODUCTION_GOOGLE_OAUTH_CLIENT_SECRET=YOUR_PRODUCTION_CLIENT_SECRET \ +PRODUCTION_GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS=YOUR_PRODUCTION_ANDROID_CLIENT_ID \ PRODUCTION_CODEX_SESSION_ID=YOUR_MAIN_CODEX_SESSION_ID \ ./scripts/init-production-env.sh whoneedhelp.com ./scripts/validate-production-env.sh .env whoneedhelp.com diff --git a/e2e/tests/mutual-aid.spec.ts b/e2e/tests/mutual-aid.spec.ts index 103e142..f88bc72 100644 --- a/e2e/tests/mutual-aid.spec.ts +++ b/e2e/tests/mutual-aid.spec.ts @@ -211,7 +211,9 @@ test("two users complete medicine tracking, handover, realtime chat, and blind r await expect( replacement.page.getByRole("heading", { name: "Private match chat" }), ).toBeVisible(); - await expect(requester.page.getByText("Helper: E2E Replacement Helper")).toBeVisible(); + await expect( + requester.page.getByRole("link", { name: "Staging E2E Replacement Helper" }), + ).toBeVisible(); await expect(helper.page.getByRole("heading", { name: "Private match chat" })).toHaveCount(0); assertRequesterClean(); diff --git a/lib/mix/tasks/wnh.staging_e2e.ex b/lib/mix/tasks/wnh.staging_e2e.ex index 8773e2f..2246a4a 100644 --- a/lib/mix/tasks/wnh.staging_e2e.ex +++ b/lib/mix/tasks/wnh.staging_e2e.ex @@ -80,7 +80,14 @@ defmodule Mix.Tasks.Wnh.StagingE2e do Repo.transaction(fn -> %{ requester: insert_user!(emails.requester, "Staging E2E Requester", password_hash, now), - helper: insert_user!(emails.helper, "Staging E2E Helper", password_hash, now) + helper: insert_user!(emails.helper, "Staging E2E Helper", password_hash, now), + replacement_helper: + insert_user!( + emails.replacement_helper, + "Staging E2E Replacement Helper", + password_hash, + now + ) } end) @@ -91,7 +98,9 @@ defmodule Mix.Tasks.Wnh.StagingE2e do "requester_id" => users.requester.id, "requester_email" => users.requester.email, "helper_id" => users.helper.id, - "helper_email" => users.helper.email + "helper_email" => users.helper.email, + "replacement_helper_id" => users.replacement_helper.id, + "replacement_helper_email" => users.replacement_helper.email } context.manifest_path |> Path.dirname() |> File.mkdir_p!() @@ -107,7 +116,8 @@ defmodule Mix.Tasks.Wnh.StagingE2e do requester_id = manifest["requester_id"] helper_id = manifest["helper_id"] - user_ids = [requester_id, helper_id] + replacement_helper_id = manifest["replacement_helper_id"] + user_ids = [requester_id, helper_id, replacement_helper_id] request_ids = HelpRequest @@ -121,7 +131,12 @@ defmodule Mix.Tasks.Wnh.StagingE2e do |> select([assignment], assignment.id) |> Repo.all() - validate_owned_domain!(user_ids, request_ids, assignment_ids, helper_id) + validate_owned_domain!( + user_ids, + request_ids, + assignment_ids, + [helper_id, replacement_helper_id] + ) {:ok, deleted} = Repo.transaction(fn -> @@ -227,8 +242,8 @@ defmodule Mix.Tasks.Wnh.StagingE2e do } end) - unless deleted.users == 2 do - Mix.raise("cleanup removed #{deleted.users} users instead of exactly 2") + unless deleted.users == 3 do + Mix.raise("cleanup removed #{deleted.users} users instead of exactly 3") end Mix.shell().info("removed exact staging E2E fixture: #{inspect(deleted)}") @@ -241,19 +256,29 @@ defmodule Mix.Tasks.Wnh.StagingE2e do manifest["database"] == context.database and manifest["requester_email"] == expected_emails.requester and manifest["helper_email"] == expected_emails.helper and - uuid?(manifest["requester_id"]) and uuid?(manifest["helper_id"]) do + manifest["replacement_helper_email"] == expected_emails.replacement_helper and + uuid?(manifest["requester_id"]) and uuid?(manifest["helper_id"]) and + uuid?(manifest["replacement_helper_id"]) do Mix.raise("staging E2E manifest does not match the requested run and database") end expected = MapSet.new([ {manifest["requester_id"], manifest["requester_email"]}, - {manifest["helper_id"], manifest["helper_email"]} + {manifest["helper_id"], manifest["helper_email"]}, + {manifest["replacement_helper_id"], manifest["replacement_helper_email"]} ]) observed = User - |> where([user], user.id in ^[manifest["requester_id"], manifest["helper_id"]]) + |> where( + [user], + user.id in ^[ + manifest["requester_id"], + manifest["helper_id"], + manifest["replacement_helper_id"] + ] + ) |> select([user], {user.id, user.email}) |> Repo.all() |> MapSet.new() @@ -263,7 +288,7 @@ defmodule Mix.Tasks.Wnh.StagingE2e do end end - defp validate_owned_domain!(user_ids, request_ids, assignment_ids, helper_id) do + defp validate_owned_domain!(user_ids, request_ids, assignment_ids, helper_ids) do unexpected_request? = Repo.exists?( from(request in HelpRequest, @@ -276,7 +301,7 @@ defmodule Mix.Tasks.Wnh.StagingE2e do from(assignment in Assignment, where: assignment.helper_id in ^user_ids and - (assignment.id not in ^assignment_ids or assignment.helper_id != ^helper_id) + (assignment.id not in ^assignment_ids or assignment.helper_id not in ^helper_ids) ) ) @@ -286,7 +311,7 @@ defmodule Mix.Tasks.Wnh.StagingE2e do unexpected_proposal? = Repo.exists?(from(proposal in CategoryProposal, where: proposal.proposer_id in ^user_ids)) - unless length(request_ids) <= 2 and length(assignment_ids) <= 2 and + unless length(request_ids) <= 2 and length(assignment_ids) <= 3 and not unexpected_request? and not unexpected_assignment? and not unexpected_activity? and not unexpected_proposal? do Mix.raise("staging E2E users own records outside the exact medicine-flow scope") @@ -309,7 +334,8 @@ defmodule Mix.Tasks.Wnh.StagingE2e do defp emails(run_id) do %{ requester: "wnh-staging-e2e-#{run_id}-requester@example.invalid", - helper: "wnh-staging-e2e-#{run_id}-helper@example.invalid" + helper: "wnh-staging-e2e-#{run_id}-helper@example.invalid", + replacement_helper: "wnh-staging-e2e-#{run_id}-replacement-helper@example.invalid" } end diff --git a/lib/who_need_help/google_auth/assent_adapter.ex b/lib/who_need_help/google_auth/assent_adapter.ex index 05b7690..bc4e411 100644 --- a/lib/who_need_help/google_auth/assent_adapter.ex +++ b/lib/who_need_help/google_auth/assent_adapter.ex @@ -4,6 +4,7 @@ defmodule WhoNeedHelp.GoogleAuth.AssentAdapter do @behaviour WhoNeedHelp.GoogleAuth alias Assent.Strategy.{Google, OIDC} + alias WhoNeedHelp.GoogleAuth.CrossClientIdTokenVerifier @impl true def enabled?, do: not is_nil(provider_config()) @@ -44,21 +45,40 @@ defmodule WhoNeedHelp.GoogleAuth.AssentAdapter do @impl true def verify_id_token(id_token, nonce) when is_binary(id_token) and id_token != "" and is_binary(nonce) and nonce != "" do - with config when is_list(config) <- provider_config(), - {:ok, jwt} <- - config - |> Keyword.put(:session_params, %{nonce: nonce}) - |> OIDC.validate_id_token(id_token) do - normalize_identity(jwt.claims) - else - nil -> {:error, :provider_disabled} - {:error, _reason} = error -> error - _invalid -> {:error, :invalid_id_token} + case provider_config() do + config when is_list(config) -> + config = Keyword.put(config, :session_params, %{nonce: nonce}) + + case OIDC.validate_id_token(config, id_token) do + {:ok, jwt} -> + normalize_identity(jwt.claims) + + {:error, reason} = error -> + verify_cross_client_id_token(config, id_token, nonce, reason, error) + end + + nil -> + {:error, :provider_disabled} end end def verify_id_token(_id_token, _nonce), do: {:error, :invalid_id_token} + defp verify_cross_client_id_token(config, id_token, nonce, reason, original_error) do + if authorization_party_mismatch?(reason) do + with {:ok, jwt} <- CrossClientIdTokenVerifier.verify(config, id_token, nonce) do + normalize_identity(jwt.claims) + end + else + original_error + end + end + + defp authorization_party_mismatch?("Invalid authorized party \"" <> authorized_party_error), + do: String.ends_with?(authorized_party_error, "\" in ID Token") + + defp authorization_party_mismatch?(_reason), do: false + def normalize_identity( %{ "sub" => provider_uid, diff --git a/lib/who_need_help/google_auth/cross_client_id_token_verifier.ex b/lib/who_need_help/google_auth/cross_client_id_token_verifier.ex new file mode 100644 index 0000000..c82f76a --- /dev/null +++ b/lib/who_need_help/google_auth/cross_client_id_token_verifier.ex @@ -0,0 +1,237 @@ +defmodule WhoNeedHelp.GoogleAuth.CrossClientIdTokenVerifier do + @moduledoc false + + alias Assent.HTTPAdapter.HTTPResponse + alias Assent.Strategy + + @required_claims ~w(iss sub aud exp iat) + + def verify(config, id_token, nonce) + when is_list(config) and is_binary(id_token) and is_binary(nonce) do + with {:ok, authorized_party_ids} <- fetch_authorized_party_ids(config), + {:ok, openid_configuration} <- fetch_openid_configuration(config), + {:ok, issuer} <- fetch_binary(openid_configuration, "issuer"), + {:ok, client_id} <- fetch_binary_config(config, :client_id), + {:ok, jwt} <- verify_jwt(id_token, openid_configuration, config), + :ok <- validate_required_claims(jwt), + :ok <- validate_issuer(jwt, issuer), + :ok <- validate_audience(jwt, client_id, config), + :ok <- validate_authorized_party(jwt, authorized_party_ids), + :ok <- validate_algorithm(jwt, config), + :ok <- validate_signature(jwt), + :ok <- validate_expiration(jwt), + :ok <- validate_issued_at(jwt, config), + :ok <- validate_nonce(jwt, nonce) do + {:ok, jwt} + end + end + + def verify(_config, _id_token, _nonce), do: {:error, :invalid_id_token} + + defp fetch_authorized_party_ids(config) do + case Keyword.get(config, :authorized_party_ids, []) do + ids when is_list(ids) and ids != [] -> + if Enum.all?(ids, &valid_identifier?/1), + do: {:ok, ids}, + else: {:error, :unauthorized_party} + + _missing_or_invalid -> + {:error, :unauthorized_party} + end + end + + defp valid_identifier?(identifier), + do: is_binary(identifier) and identifier != "" and String.trim(identifier) == identifier + + defp fetch_openid_configuration(config) do + case Keyword.get(config, :openid_configuration) do + configuration when is_map(configuration) -> + {:ok, configuration} + + nil -> + with {:ok, base_url} <- fetch_binary_config(config, :base_url) do + path = + Keyword.get( + config, + :openid_configuration_uri, + "/.well-known/openid-configuration" + ) + + base_url + |> Strategy.to_url(path) + |> fetch_json(config, :openid_configuration_unavailable) + end + + _invalid -> + {:error, :invalid_openid_configuration} + end + end + + defp fetch_json(url, config, error_name) do + case Strategy.http_request(:get, url, nil, [], config) do + {:ok, %HTTPResponse{status: 200, body: body}} when is_map(body) -> + {:ok, body} + + _unavailable_or_invalid -> + {:error, error_name} + end + end + + defp verify_jwt(id_token, openid_configuration, config) do + with {:ok, header} <- peek_header(id_token, config), + {:ok, verification_key} <- + fetch_verification_key(header, openid_configuration, config) do + Strategy.verify_jwt(id_token, verification_key, config) + end + end + + defp peek_header(id_token, config) do + with [encoded_header, _claims, _signature] <- String.split(id_token, "."), + {:ok, json} <- Base.url_decode64(encoded_header, padding: false), + {:ok, header} when is_map(header) <- Assent.json_library(config).decode(json) do + {:ok, header} + else + _invalid -> {:error, :invalid_id_token} + end + end + + defp fetch_verification_key(%{"alg" => "HS" <> _rest}, _openid_configuration, config), + do: fetch_binary_config(config, :client_secret) + + defp fetch_verification_key(header, openid_configuration, config) do + with {:ok, jwks_uri} <- fetch_binary(openid_configuration, "jwks_uri"), + {:ok, %{"keys" => keys}} <- + fetch_json(jwks_uri, config, :signing_keys_unavailable), + true <- is_list(keys), + {:ok, key} <- find_verification_key(header, keys) do + {:ok, key} + else + false -> {:error, :invalid_signing_keys} + {:error, _reason} = error -> error + _invalid -> {:error, :invalid_signing_keys} + end + end + + defp find_verification_key(%{"kid" => kid}, keys) when is_binary(kid) do + case Enum.find(keys, &(is_map(&1) and Map.get(&1, "kid") == kid)) do + nil -> {:error, :signing_key_not_found} + key -> {:ok, key} + end + end + + defp find_verification_key(_header, [key]) when is_map(key), do: {:ok, key} + defp find_verification_key(_header, _keys), do: {:error, :signing_key_not_found} + + defp validate_required_claims(%{claims: claims}) when is_map(claims) do + valid? = + Enum.all?(@required_claims, &Map.has_key?(claims, &1)) and + non_empty_binary?(claims["iss"]) and + non_empty_binary?(claims["sub"]) and + valid_audience_claim?(claims["aud"]) and + is_integer(claims["exp"]) and + is_integer(claims["iat"]) + + if valid?, do: :ok, else: {:error, :invalid_required_claims} + end + + defp validate_required_claims(_jwt), do: {:error, :invalid_required_claims} + + defp validate_issuer(%{claims: %{"iss" => issuer}}, issuer), do: :ok + defp validate_issuer(_jwt, _issuer), do: {:error, :invalid_issuer} + + defp validate_audience(%{claims: %{"aud" => audience}}, client_id, config) do + audiences = List.wrap(audience) + trusted = [client_id | Keyword.get(config, :trusted_audiences, [])] + + if client_id in audiences and Enum.all?(audiences, &(&1 in trusted)), + do: :ok, + else: {:error, :invalid_audience} + end + + defp validate_audience(_jwt, _client_id, _config), do: {:error, :invalid_audience} + + defp validate_authorized_party( + %{claims: %{"azp" => authorized_party}}, + authorized_party_ids + ) + when is_binary(authorized_party) do + if authorized_party in authorized_party_ids, + do: :ok, + else: {:error, :unauthorized_party} + end + + defp validate_authorized_party(_jwt, _authorized_party_ids), + do: {:error, :unauthorized_party} + + defp validate_algorithm(%{header: %{"alg" => algorithm}}, config) do + if algorithm == Keyword.get(config, :id_token_signed_response_alg, "RS256"), + do: :ok, + else: {:error, :invalid_algorithm} + end + + defp validate_algorithm(_jwt, _config), do: {:error, :invalid_algorithm} + + defp validate_signature(%{verified?: true}), do: :ok + defp validate_signature(_jwt), do: {:error, :invalid_signature} + + defp validate_expiration(%{claims: %{"exp" => expires_at}}) when is_integer(expires_at) do + if expires_at > System.system_time(:second), + do: :ok, + else: {:error, :expired_id_token} + end + + defp validate_expiration(_jwt), do: {:error, :invalid_expiration} + + defp validate_issued_at(%{claims: %{"iat" => issued_at}}, config) + when is_integer(issued_at) do + now = System.system_time(:second) + + case Keyword.get(config, :id_token_ttl_seconds) do + nil when issued_at <= now -> + :ok + + ttl when is_integer(ttl) and ttl > 0 and issued_at <= now and issued_at + ttl > now -> + :ok + + nil -> + {:error, :invalid_issued_at} + + _invalid_or_expired -> + {:error, :invalid_issued_at} + end + end + + defp validate_issued_at(_jwt, _config), do: {:error, :invalid_issued_at} + + defp validate_nonce(%{claims: %{"nonce" => provided_nonce}}, stored_nonce) + when is_binary(provided_nonce) do + if Assent.constant_time_compare(stored_nonce, provided_nonce), + do: :ok, + else: {:error, :invalid_nonce} + end + + defp validate_nonce(_jwt, _stored_nonce), do: {:error, :invalid_nonce} + + defp fetch_binary(map, key) do + case Map.get(map, key) do + value when is_binary(value) and value != "" -> {:ok, value} + _missing_or_invalid -> {:error, :invalid_openid_configuration} + end + end + + defp fetch_binary_config(config, key) do + case Keyword.get(config, key) do + value when is_binary(value) and value != "" -> {:ok, value} + _missing_or_invalid -> {:error, :invalid_provider_configuration} + end + end + + defp non_empty_binary?(value), do: is_binary(value) and value != "" + + defp valid_audience_claim?(audience) when is_binary(audience), do: audience != "" + + defp valid_audience_claim?(audiences) when is_list(audiences), + do: audiences != [] and Enum.all?(audiences, &non_empty_binary?/1) + + defp valid_audience_claim?(_audience), do: false +end diff --git a/scripts/check-environment-readiness.sh b/scripts/check-environment-readiness.sh index fdd4040..d2d5324 100755 --- a/scripts/check-environment-readiness.sh +++ b/scripts/check-environment-readiness.sh @@ -117,6 +117,19 @@ valid_sha256_fingerprint_list() { done } +valid_nonempty_csv() { + local item compact + local -a items + + IFS=',' read -r -a items <<<"$1" + [[ ${#items[@]} -gt 0 ]] || return 1 + + for item in "${items[@]}"; do + compact=${item//[[:space:]]/} + [[ -n "$compact" ]] || return 1 + done +} + failures=0 warnings=0 @@ -196,6 +209,18 @@ else partial "Google sign-in" "client ID and secret must be configured together" fi +if is_set GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS; then + if ! all_set GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET; then + invalid "Android Google sign-in" "authorized parties require the Google OAuth client" + elif valid_nonempty_csv "$(value GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS)"; then + ready "Android Google sign-in" "one or more authorized Android OAuth clients" + else + invalid "Android Google sign-in" "authorized party IDs must be a non-empty CSV list" + fi +else + missing "Android Google sign-in" "GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS" +fi + if all_empty WEB_PUSH_VAPID_PUBLIC_KEY WEB_PUSH_VAPID_PRIVATE_KEY WEB_PUSH_VAPID_SUBJECT; then missing "browser Web Push" "VAPID public/private keys and subject" elif all_set WEB_PUSH_VAPID_PUBLIC_KEY WEB_PUSH_VAPID_PRIVATE_KEY WEB_PUSH_VAPID_SUBJECT; then diff --git a/scripts/init-production-env.sh b/scripts/init-production-env.sh index c47a36f..5b6dca5 100755 --- a/scripts/init-production-env.sh +++ b/scripts/init-production-env.sh @@ -78,6 +78,7 @@ public_upstream_name=${PRODUCTION_PUBLIC_UPSTREAM_NAME:-who-need-help-production codex_session_id=${PRODUCTION_CODEX_SESSION_ID:-} google_oauth_client_id=${PRODUCTION_GOOGLE_OAUTH_CLIENT_ID:-} google_oauth_client_secret=${PRODUCTION_GOOGLE_OAUTH_CLIENT_SECRET:-} +google_oauth_authorized_party_ids=${PRODUCTION_GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS:-} web_push_vapid_public_key=${PRODUCTION_WEB_PUSH_VAPID_PUBLIC_KEY:-} web_push_vapid_private_key=${PRODUCTION_WEB_PUSH_VAPID_PRIVATE_KEY:-} web_push_vapid_subject=${PRODUCTION_WEB_PUSH_VAPID_SUBJECT:-} @@ -104,6 +105,8 @@ require_single_line_env_value PRODUCTION_PUBLIC_UPSTREAM_NAME "$public_upstream_ require_single_line_env_value PRODUCTION_CODEX_SESSION_ID "$codex_session_id" require_single_line_env_value PRODUCTION_GOOGLE_OAUTH_CLIENT_ID "$google_oauth_client_id" require_single_line_env_value PRODUCTION_GOOGLE_OAUTH_CLIENT_SECRET "$google_oauth_client_secret" +require_single_line_env_value \ + PRODUCTION_GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS "$google_oauth_authorized_party_ids" require_single_line_env_value PRODUCTION_WEB_PUSH_VAPID_PUBLIC_KEY "$web_push_vapid_public_key" require_single_line_env_value PRODUCTION_WEB_PUSH_VAPID_PRIVATE_KEY "$web_push_vapid_private_key" require_single_line_env_value PRODUCTION_WEB_PUSH_VAPID_SUBJECT "$web_push_vapid_subject" @@ -130,6 +133,17 @@ if { [ -n "$google_oauth_client_id" ] || [ -n "$google_oauth_client_secret" ]; } echo "Production Google OAuth client ID and secret must either both be set or both be empty." >&2 exit 1 fi +if [ -n "$google_oauth_authorized_party_ids" ] && + { [ -z "$google_oauth_client_id" ] || [ -z "$google_oauth_client_secret" ]; }; then + echo "Production Android Google authorized parties require the Google OAuth client." >&2 + exit 1 +fi +case "$google_oauth_authorized_party_ids" in + ,* | *,,* | *,) + echo "PRODUCTION_GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS contains an empty value." >&2 + exit 1 + ;; +esac if { [ -n "$android_app_links_package_name" ] || @@ -395,6 +409,7 @@ CODEX_SESSION_ID_VALUE=$codex_session_id \ GIT_SHA_VALUE=$git_sha \ GOOGLE_OAUTH_CLIENT_ID_VALUE=$google_oauth_client_id \ GOOGLE_OAUTH_CLIENT_SECRET_VALUE=$google_oauth_client_secret \ +GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS_VALUE=$google_oauth_authorized_party_ids \ WEB_PUSH_VAPID_PUBLIC_KEY_VALUE=$web_push_vapid_public_key \ WEB_PUSH_VAPID_PRIVATE_KEY_VALUE=$web_push_vapid_private_key \ WEB_PUSH_VAPID_SUBJECT_VALUE=$web_push_vapid_subject \ @@ -462,6 +477,7 @@ TEST_UPSTREAM_VALUE=$test_upstream \ replacement["SUPPORT_INBOX_ADDRESS"] = ENVIRON["SUPPORT_INBOX_ADDRESS_VALUE"] replacement["GOOGLE_OAUTH_CLIENT_ID"] = ENVIRON["GOOGLE_OAUTH_CLIENT_ID_VALUE"] replacement["GOOGLE_OAUTH_CLIENT_SECRET"] = ENVIRON["GOOGLE_OAUTH_CLIENT_SECRET_VALUE"] + replacement["GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS"] = ENVIRON["GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS_VALUE"] replacement["WEB_PUSH_VAPID_PUBLIC_KEY"] = ENVIRON["WEB_PUSH_VAPID_PUBLIC_KEY_VALUE"] replacement["WEB_PUSH_VAPID_PRIVATE_KEY"] = ENVIRON["WEB_PUSH_VAPID_PRIVATE_KEY_VALUE"] replacement["WEB_PUSH_VAPID_SUBJECT"] = ENVIRON["WEB_PUSH_VAPID_SUBJECT_VALUE"] diff --git a/scripts/init-test-env.sh b/scripts/init-test-env.sh index f798130..bedfc76 100755 --- a/scripts/init-test-env.sh +++ b/scripts/init-test-env.sh @@ -74,6 +74,7 @@ mailpit_port=${TEST_MAILPIT_PORT:-8027} codex_session_id=${TEST_CODEX_SESSION_ID:-} google_oauth_client_id=${TEST_GOOGLE_OAUTH_CLIENT_ID:-} google_oauth_client_secret=${TEST_GOOGLE_OAUTH_CLIENT_SECRET:-} +google_oauth_authorized_party_ids=${TEST_GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS:-} web_push_vapid_public_key=${TEST_WEB_PUSH_VAPID_PUBLIC_KEY:-} web_push_vapid_private_key=${TEST_WEB_PUSH_VAPID_PRIVATE_KEY:-} web_push_vapid_subject=${TEST_WEB_PUSH_VAPID_SUBJECT:-} @@ -98,6 +99,8 @@ require_single_line_env_value TEST_MAILPIT_PORT "$mailpit_port" require_single_line_env_value TEST_CODEX_SESSION_ID "$codex_session_id" require_single_line_env_value TEST_GOOGLE_OAUTH_CLIENT_ID "$google_oauth_client_id" require_single_line_env_value TEST_GOOGLE_OAUTH_CLIENT_SECRET "$google_oauth_client_secret" +require_single_line_env_value \ + TEST_GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS "$google_oauth_authorized_party_ids" require_single_line_env_value TEST_WEB_PUSH_VAPID_PUBLIC_KEY "$web_push_vapid_public_key" require_single_line_env_value TEST_WEB_PUSH_VAPID_PRIVATE_KEY "$web_push_vapid_private_key" require_single_line_env_value TEST_WEB_PUSH_VAPID_SUBJECT "$web_push_vapid_subject" @@ -133,6 +136,17 @@ if [[ -n "$google_oauth_client_id" || -n "$google_oauth_client_secret" ]]; then exit 1 } fi +if [[ -n "$google_oauth_authorized_party_ids" && + (-z "$google_oauth_client_id" || -z "$google_oauth_client_secret") ]]; then + echo "Test Android Google authorized parties require the Google OAuth client." >&2 + exit 1 +fi +if [[ "$google_oauth_authorized_party_ids" == ,* || + "$google_oauth_authorized_party_ids" == *,,* || + "$google_oauth_authorized_party_ids" == *, ]]; then + echo "TEST_GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS contains an empty value." >&2 + exit 1 +fi if [[ -n "$android_app_links_package_name" || -n "$android_app_links_fingerprints" ]]; then [[ -n "$android_app_links_package_name" && -n "$android_app_links_fingerprints" ]] || { @@ -270,6 +284,7 @@ RELEASE_COOKIE_VALUE=$release_cookie \ METRICS_TOKEN_VALUE=$metrics_token \ GOOGLE_OAUTH_CLIENT_ID_VALUE=$google_oauth_client_id \ GOOGLE_OAUTH_CLIENT_SECRET_VALUE=$google_oauth_client_secret \ +GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS_VALUE=$google_oauth_authorized_party_ids \ WEB_PUSH_VAPID_PUBLIC_KEY_VALUE=$web_push_vapid_public_key \ WEB_PUSH_VAPID_PRIVATE_KEY_VALUE=$web_push_vapid_private_key \ WEB_PUSH_VAPID_SUBJECT_VALUE=$web_push_vapid_subject \ @@ -332,6 +347,7 @@ CODEX_SESSION_ID_VALUE=$codex_session_id \ replacement["SUPPORT_INBOX_ADDRESS"] = ENVIRON["SUPPORT_INBOX_ADDRESS_VALUE"] replacement["GOOGLE_OAUTH_CLIENT_ID"] = ENVIRON["GOOGLE_OAUTH_CLIENT_ID_VALUE"] replacement["GOOGLE_OAUTH_CLIENT_SECRET"] = ENVIRON["GOOGLE_OAUTH_CLIENT_SECRET_VALUE"] + replacement["GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS"] = ENVIRON["GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS_VALUE"] replacement["WEB_PUSH_VAPID_PUBLIC_KEY"] = ENVIRON["WEB_PUSH_VAPID_PUBLIC_KEY_VALUE"] replacement["WEB_PUSH_VAPID_PRIVATE_KEY"] = ENVIRON["WEB_PUSH_VAPID_PRIVATE_KEY_VALUE"] replacement["WEB_PUSH_VAPID_SUBJECT"] = ENVIRON["WEB_PUSH_VAPID_SUBJECT_VALUE"] diff --git a/scripts/quality.sh b/scripts/quality.sh index ddf96b2..cfc50ef 100755 --- a/scripts/quality.sh +++ b/scripts/quality.sh @@ -332,6 +332,8 @@ printf '%s\n' \ 'PHX_SCHEME=https' \ 'PHX_URL_PORT=443' \ 'WNH_BASE_URL=https://dev.help.test' \ + 'GOOGLE_OAUTH_CLIENT_ID=quality-web-client' \ + 'GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS=quality-android-client' \ 'ANDROID_APP_LINKS_PACKAGE_NAME=org.whoneedhelp.mobile.development' \ "ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS=$android_fingerprint" \ 'ANDROID_PLAY_APP_SIGNING_SHA256_CERT_FINGERPRINTS=' \ @@ -340,6 +342,16 @@ chmod 600 "$android_env" ./scripts/validate-android-environment.sh \ "$android_env" development >/dev/null +android_missing_authorized_party_env="$scan_dir/android-missing-authorized-party.env" +grep -v '^GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS=' \ + "$android_env" >"$android_missing_authorized_party_env" +chmod 600 "$android_missing_authorized_party_env" +if ./scripts/validate-android-environment.sh \ + "$android_missing_authorized_party_env" development >/dev/null 2>&1; then + echo "Android validation accepted an empty Google authorized-party allowlist." >&2 + exit 1 +fi + sed -i \ 's|^ANDROID_APP_LINKS_PACKAGE_NAME=.*|ANDROID_APP_LINKS_PACKAGE_NAME=org.whoneedhelp.mobile.staging|' \ "$android_env" @@ -457,6 +469,7 @@ fi TEST_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \ TEST_GOOGLE_OAUTH_CLIENT_ID=quality-test-client \ TEST_GOOGLE_OAUTH_CLIENT_SECRET="quality-test\$secret" \ +TEST_GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS=quality-test-android-client \ TEST_WEB_PUSH_VAPID_PUBLIC_KEY=quality-test-vapid-public \ TEST_WEB_PUSH_VAPID_PRIVATE_KEY=quality-test-vapid-private \ TEST_WEB_PUSH_VAPID_SUBJECT=mailto:test@help.test \ @@ -486,6 +499,8 @@ grep -Fx 'SMTP_RELAY=mailpit' "$test_env" >/dev/null grep -Fx 'EMAIL_FROM_NAME="Who Need Help Test"' "$test_env" >/dev/null grep -Fx 'GOOGLE_OAUTH_CLIENT_ID=quality-test-client' "$test_env" >/dev/null grep -Fx "GOOGLE_OAUTH_CLIENT_SECRET=quality-test\$\$secret" "$test_env" >/dev/null +grep -Fx 'GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS=quality-test-android-client' \ + "$test_env" >/dev/null grep -Fx 'WEB_PUSH_VAPID_SUBJECT=mailto:test@help.test' "$test_env" >/dev/null grep -Fx 'WNH_FIREBASE_APPLICATION_ID=1:123456789:android:quality-test' "$test_env" >/dev/null grep -Fx 'WNH_FIREBASE_PROJECT_ID=quality-test' "$test_env" >/dev/null @@ -592,6 +607,7 @@ PRODUCTION_EMAIL_FROM_ADDRESS=contact@help.test \ PRODUCTION_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \ PRODUCTION_GOOGLE_OAUTH_CLIENT_ID=quality-production-client \ PRODUCTION_GOOGLE_OAUTH_CLIENT_SECRET=quality-production-secret \ +PRODUCTION_GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS=quality-production-android-client \ PRODUCTION_WEB_PUSH_VAPID_PUBLIC_KEY=quality-vapid-public \ PRODUCTION_WEB_PUSH_VAPID_PRIVATE_KEY=quality-vapid-private \ PRODUCTION_WEB_PUSH_VAPID_SUBJECT=mailto:contact@help.test \ @@ -608,6 +624,8 @@ PRODUCTION_SUPPORT_INBOX_ADDRESS=support@help.test \ ./scripts/init-production-env.sh help.test "$production_env" >/dev/null test "$(stat -c '%a' "$production_env")" = 600 grep -Fx "SMTP_PASSWORD=quality\$\$password" "$production_env" >/dev/null +grep -Fx 'GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS=quality-production-android-client' \ + "$production_env" >/dev/null ./scripts/validate-production-env.sh "$production_env" help.test >/dev/null ./scripts/check-environment-readiness.sh "$production_env" --require-release >/dev/null if PRODUCTION_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \ diff --git a/scripts/validate-android-environment.sh b/scripts/validate-android-environment.sh index 3e540fb..dd1bfcb 100755 --- a/scripts/validate-android-environment.sh +++ b/scripts/validate-android-environment.sh @@ -63,6 +63,8 @@ phx_host=$(read_unique PHX_HOST) phx_scheme=$(read_unique PHX_SCHEME) phx_port=$(read_unique PHX_URL_PORT) base_url=$(read_unique WNH_BASE_URL) +google_oauth_client_id=$(read_unique GOOGLE_OAUTH_CLIENT_ID) +google_oauth_authorized_party_ids=$(read_unique GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS) app_links_package=$(read_unique ANDROID_APP_LINKS_PACKAGE_NAME) app_links_fingerprints=$(read_unique ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS) play_app_signing_fingerprints= @@ -102,6 +104,22 @@ fi echo "WNH_BASE_URL must equal the canonical PHX origin: $expected_origin" >&2 exit 1 } +[[ -n "$google_oauth_client_id" ]] || { + echo "Android builds require the environment's Google Web OAuth client ID." >&2 + exit 1 +} +[[ -n "$google_oauth_authorized_party_ids" ]] || { + echo "Android builds require at least one authorized Android Google OAuth client ID." >&2 + exit 1 +} +IFS=',' read -r -a google_authorized_parties <<<"$google_oauth_authorized_party_ids" +for authorized_party in "${google_authorized_parties[@]}"; do + compact_party=${authorized_party//[[:space:]]/} + [[ -n "$compact_party" ]] || { + echo "GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS contains an empty value." >&2 + exit 1 + } +done IFS=',' read -r -a fingerprints <<<"$app_links_fingerprints" for fingerprint in "${fingerprints[@]}"; do diff --git a/scripts/validate-deployment-isolation.sh b/scripts/validate-deployment-isolation.sh index 92d12c4..8907218 100755 --- a/scripts/validate-deployment-isolation.sh +++ b/scripts/validate-deployment-isolation.sh @@ -94,6 +94,21 @@ if [[ -n "$test_google_id" || -n "$production_google_id" ]]; then } fi +test_google_authorized_parties=$( + read_env "$test_env" GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS 2>/dev/null || true +) +production_google_authorized_parties=$( + read_env "$production_env" GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS 2>/dev/null || true +) +if [[ -n "$test_google_authorized_parties" || -n "$production_google_authorized_parties" ]]; then + [[ -n "$test_google_authorized_parties" && + -n "$production_google_authorized_parties" && + "$test_google_authorized_parties" != "$production_google_authorized_parties" ]] || { + echo "Configured test and production Android Google clients must be different." >&2 + exit 1 + } +fi + test_email_provider=$(read_env "$test_env" EMAIL_DELIVERY_PROVIDER) production_email_provider=$(read_env "$production_env" EMAIL_DELIVERY_PROVIDER) diff --git a/scripts/validate-production-env.sh b/scripts/validate-production-env.sh index 83d16f4..936d537 100755 --- a/scripts/validate-production-env.sh +++ b/scripts/validate-production-env.sh @@ -68,6 +68,19 @@ valid_fcm_service_account_json() { ' >/dev/null 2>&1 } +valid_nonempty_csv() { + local item compact + local -a items + + IFS=',' read -r -a items <<<"$1" + [[ ${#items[@]} -gt 0 ]] || return 1 + + for item in "${items[@]}"; do + compact=${item//[[:space:]]/} + [[ -n "$compact" ]] || return 1 + done +} + reject_marker() { local key=$1 local value=$2 @@ -119,6 +132,7 @@ email_from_address=$(require_value EMAIL_FROM_ADDRESS) support_inbox_address=$(optional_value SUPPORT_INBOX_ADDRESS) google_oauth_client_id=$(optional_value GOOGLE_OAUTH_CLIENT_ID) google_oauth_client_secret=$(optional_value GOOGLE_OAUTH_CLIENT_SECRET) +google_oauth_authorized_party_ids=$(optional_value GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS) web_push_vapid_public_key=$(optional_value WEB_PUSH_VAPID_PUBLIC_KEY) web_push_vapid_private_key=$(optional_value WEB_PUSH_VAPID_PRIVATE_KEY) web_push_vapid_subject=$(optional_value WEB_PUSH_VAPID_SUBJECT) @@ -337,6 +351,19 @@ if [[ -n "$google_oauth_client_id" || -n "$google_oauth_client_secret" ]]; then reject_marker GOOGLE_OAUTH_CLIENT_ID "$google_oauth_client_id" reject_marker GOOGLE_OAUTH_CLIENT_SECRET "$google_oauth_client_secret" fi +if [[ -n "$google_oauth_authorized_party_ids" ]]; then + [[ -n "$google_oauth_client_id" && -n "$google_oauth_client_secret" ]] || { + echo "Android Google authorized parties require the Google OAuth client." >&2 + exit 1 + } + + reject_marker GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS "$google_oauth_authorized_party_ids" + + valid_nonempty_csv "$google_oauth_authorized_party_ids" || { + echo "GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS must be a non-empty CSV list." >&2 + exit 1 + } +fi firebase_values=( "$firebase_application_id" diff --git a/scripts/validate-test-env.sh b/scripts/validate-test-env.sh index 4271b96..ce12ba2 100755 --- a/scripts/validate-test-env.sh +++ b/scripts/validate-test-env.sh @@ -50,6 +50,19 @@ valid_fcm_service_account_json() { ' >/dev/null 2>&1 } +valid_nonempty_csv() { + local item compact + local -a items + + IFS=',' read -r -a items <<<"$1" + [[ ${#items[@]} -gt 0 ]] || return 1 + + for item in "${items[@]}"; do + compact=${item//[[:space:]]/} + [[ -n "$compact" ]] || return 1 + done +} + [[ "$(require_value DEPLOYMENT_ENV)" == test ]] || { echo "Test validation requires DEPLOYMENT_ENV=test." >&2 exit 1 @@ -126,12 +139,24 @@ require_value EMAIL_FROM_ADDRESS >/dev/null google_id=$(read_value GOOGLE_OAUTH_CLIENT_ID 2>/dev/null || true) google_secret=$(read_value GOOGLE_OAUTH_CLIENT_SECRET 2>/dev/null || true) +google_authorized_party_ids=$( + read_value GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS 2>/dev/null || true +) if [[ -n "$google_id" || -n "$google_secret" ]]; then [[ -n "$google_id" && -n "$google_secret" ]] || { echo "Test Google OAuth ID and secret must be configured together." >&2 exit 1 } fi +if [[ -n "$google_authorized_party_ids" && (-z "$google_id" || -z "$google_secret") ]]; then + echo "Test Android Google authorized parties require the Google OAuth client." >&2 + exit 1 +fi +if [[ -n "$google_authorized_party_ids" ]] && + ! valid_nonempty_csv "$google_authorized_party_ids"; then + echo "Test GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS must be a non-empty CSV list." >&2 + exit 1 +fi firebase_values=( "$(read_value WNH_FIREBASE_APPLICATION_ID 2>/dev/null || true)" diff --git a/test/who_need_help/google_auth_test.exs b/test/who_need_help/google_auth_test.exs index 872ed69..d5372f6 100644 --- a/test/who_need_help/google_auth_test.exs +++ b/test/who_need_help/google_auth_test.exs @@ -3,6 +3,25 @@ defmodule WhoNeedHelp.GoogleAuthTest do 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" @@ -145,10 +164,217 @@ defmodule WhoNeedHelp.GoogleAuthTest do 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" + }} = 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" => "HS256"}, claims) + |> JOSE.JWT.sign(%{"alg" => algorithm}, claims) |> JOSE.JWS.compact() |> elem(1) end