fix(auth): verify Android Google sign-in end to end

This commit is contained in:
SimpleTest 2026-07-25 00:36:33 +03:00
parent 22e83a420b
commit 51d55fa73f
18 changed files with 721 additions and 28 deletions

View File

@ -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=

View File

@ -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.

View File

@ -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:-}

View File

@ -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")}

View File

@ -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

View File

@ -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();

View File

@ -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

View File

@ -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
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)
else
nil -> {:error, :provider_disabled}
{:error, _reason} = error -> error
_invalid -> {:error, :invalid_id_token}
{: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,

View File

@ -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

View File

@ -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

View File

@ -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"]

View File

@ -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"]

View File

@ -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 \

View File

@ -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

View File

@ -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)

View File

@ -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"

View File

@ -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)"

View File

@ -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