fix(auth): verify Android Google sign-in end to end
This commit is contained in:
parent
22e83a420b
commit
51d55fa73f
|
|
@ -140,6 +140,9 @@ GITHUB_OAUTH_HTTP_RECEIVE_TIMEOUT_MS=
|
||||||
# https://YOUR_PHX_HOST/auth/google/callback
|
# https://YOUR_PHX_HOST/auth/google/callback
|
||||||
GOOGLE_OAUTH_CLIENT_ID=
|
GOOGLE_OAUTH_CLIENT_ID=
|
||||||
GOOGLE_OAUTH_CLIENT_SECRET=
|
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
|
# Leave endpoint and timeout overrides empty for Google's discovery endpoint
|
||||||
# and Req defaults. The base URL override exists for isolated protocol tests.
|
# and Req defaults. The base URL override exists for isolated protocol tests.
|
||||||
GOOGLE_OAUTH_BASE_URL=
|
GOOGLE_OAUTH_BASE_URL=
|
||||||
|
|
|
||||||
11
README.md
11
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
|
an explanation. A partial pair is rejected at startup and by the production
|
||||||
environment validator.
|
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,
|
The flow requests `openid email profile`, verifies the provider email claim,
|
||||||
uses state, nonce, and PKCE, and discards provider tokens. A new Google identity
|
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
|
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
|
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
|
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
|
session-bound one-time nonce, and sends the resulting ID token directly back to
|
||||||
the server. The server verifies the signature, issuer, audience, expiration,
|
the server. The server verifies the signature, algorithm, issuer, Web audience,
|
||||||
verified email, and nonce before it reuses the ordinary login, registration, or
|
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
|
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.
|
it nor the ID token is exposed to WebView JavaScript or compiled into the APK.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ x-app-environment: &app-environment
|
||||||
GITHUB_OAUTH_HTTP_RECEIVE_TIMEOUT_MS: ${GITHUB_OAUTH_HTTP_RECEIVE_TIMEOUT_MS:-}
|
GITHUB_OAUTH_HTTP_RECEIVE_TIMEOUT_MS: ${GITHUB_OAUTH_HTTP_RECEIVE_TIMEOUT_MS:-}
|
||||||
GOOGLE_OAUTH_CLIENT_ID: ${GOOGLE_OAUTH_CLIENT_ID:-}
|
GOOGLE_OAUTH_CLIENT_ID: ${GOOGLE_OAUTH_CLIENT_ID:-}
|
||||||
GOOGLE_OAUTH_CLIENT_SECRET: ${GOOGLE_OAUTH_CLIENT_SECRET:-}
|
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_BASE_URL: ${GOOGLE_OAUTH_BASE_URL:-}
|
||||||
GOOGLE_OAUTH_HTTP_CONNECT_TIMEOUT_MS: ${GOOGLE_OAUTH_HTTP_CONNECT_TIMEOUT_MS:-}
|
GOOGLE_OAUTH_HTTP_CONNECT_TIMEOUT_MS: ${GOOGLE_OAUTH_HTTP_CONNECT_TIMEOUT_MS:-}
|
||||||
GOOGLE_OAUTH_HTTP_RECEIVE_TIMEOUT_MS: ${GOOGLE_OAUTH_HTTP_RECEIVE_TIMEOUT_MS:-}
|
GOOGLE_OAUTH_HTTP_RECEIVE_TIMEOUT_MS: ${GOOGLE_OAUTH_HTTP_RECEIVE_TIMEOUT_MS:-}
|
||||||
|
|
|
||||||
|
|
@ -203,6 +203,12 @@ config :who_need_help, :social_oauth, github_oauth
|
||||||
[
|
[
|
||||||
client_id: client_id,
|
client_id: client_id,
|
||||||
client_secret: client_secret,
|
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,
|
base_url: base_url,
|
||||||
authorization_params: [scope: "email profile"],
|
authorization_params: [scope: "email profile"],
|
||||||
http_adapter: {Assent.HTTPAdapter.Req, oauth_http_options.("GOOGLE")}
|
http_adapter: {Assent.HTTPAdapter.Req, oauth_http_options.("GOOGLE")}
|
||||||
|
|
|
||||||
|
|
@ -183,7 +183,7 @@ test checkout. The generated file uses the ordinary runtime names:
|
||||||
|
|
||||||
| Capability | Values kept in that checkout's `.env` |
|
| 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 |
|
| Browser push | three `WEB_PUSH_VAPID_*` values |
|
||||||
| Android Firebase client | four public `WNH_FIREBASE_*` values |
|
| Android Firebase client | four public `WNH_FIREBASE_*` values |
|
||||||
| Android delivery | `FCM_PROJECT_ID` and one private service-account source |
|
| 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,
|
`org.whoneedhelp.mobile.staging` plus the stable staging certificate for test,
|
||||||
and `org.whoneedhelp.mobile` plus the Play-distributed certificate for
|
and `org.whoneedhelp.mobile` plus the Play-distributed certificate for
|
||||||
production. Do not add a second Google secret file or Gradle property.
|
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:
|
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_CODEX_SESSION_ID=YOUR_MAIN_CODEX_SESSION_ID \
|
||||||
TEST_GOOGLE_OAUTH_CLIENT_ID=YOUR_TEST_CLIENT_ID \
|
TEST_GOOGLE_OAUTH_CLIENT_ID=YOUR_TEST_CLIENT_ID \
|
||||||
TEST_GOOGLE_OAUTH_CLIENT_SECRET=YOUR_TEST_CLIENT_SECRET \
|
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/init-test-env.sh test.whoneedhelp.com
|
||||||
./scripts/validate-test-env.sh .env test.whoneedhelp.com
|
./scripts/validate-test-env.sh .env test.whoneedhelp.com
|
||||||
./scripts/deploy-up.sh .env
|
./scripts/deploy-up.sh .env
|
||||||
|
|
@ -293,6 +297,7 @@ PRODUCTION_SMTP_USERNAME=YOUR_PRODUCTION_SMTP_LOGIN \
|
||||||
PRODUCTION_SMTP_PASSWORD=YOUR_PRODUCTION_SMTP_PASSWORD \
|
PRODUCTION_SMTP_PASSWORD=YOUR_PRODUCTION_SMTP_PASSWORD \
|
||||||
PRODUCTION_GOOGLE_OAUTH_CLIENT_ID=YOUR_PRODUCTION_CLIENT_ID \
|
PRODUCTION_GOOGLE_OAUTH_CLIENT_ID=YOUR_PRODUCTION_CLIENT_ID \
|
||||||
PRODUCTION_GOOGLE_OAUTH_CLIENT_SECRET=YOUR_PRODUCTION_CLIENT_SECRET \
|
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 \
|
PRODUCTION_CODEX_SESSION_ID=YOUR_MAIN_CODEX_SESSION_ID \
|
||||||
./scripts/init-production-env.sh whoneedhelp.com
|
./scripts/init-production-env.sh whoneedhelp.com
|
||||||
./scripts/validate-production-env.sh .env whoneedhelp.com
|
./scripts/validate-production-env.sh .env whoneedhelp.com
|
||||||
|
|
|
||||||
|
|
@ -211,7 +211,9 @@ test("two users complete medicine tracking, handover, realtime chat, and blind r
|
||||||
await expect(
|
await expect(
|
||||||
replacement.page.getByRole("heading", { name: "Private match chat" }),
|
replacement.page.getByRole("heading", { name: "Private match chat" }),
|
||||||
).toBeVisible();
|
).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);
|
await expect(helper.page.getByRole("heading", { name: "Private match chat" })).toHaveCount(0);
|
||||||
|
|
||||||
assertRequesterClean();
|
assertRequesterClean();
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,14 @@ defmodule Mix.Tasks.Wnh.StagingE2e do
|
||||||
Repo.transaction(fn ->
|
Repo.transaction(fn ->
|
||||||
%{
|
%{
|
||||||
requester: insert_user!(emails.requester, "Staging E2E Requester", password_hash, now),
|
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)
|
end)
|
||||||
|
|
||||||
|
|
@ -91,7 +98,9 @@ defmodule Mix.Tasks.Wnh.StagingE2e do
|
||||||
"requester_id" => users.requester.id,
|
"requester_id" => users.requester.id,
|
||||||
"requester_email" => users.requester.email,
|
"requester_email" => users.requester.email,
|
||||||
"helper_id" => users.helper.id,
|
"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!()
|
context.manifest_path |> Path.dirname() |> File.mkdir_p!()
|
||||||
|
|
@ -107,7 +116,8 @@ defmodule Mix.Tasks.Wnh.StagingE2e do
|
||||||
|
|
||||||
requester_id = manifest["requester_id"]
|
requester_id = manifest["requester_id"]
|
||||||
helper_id = manifest["helper_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 =
|
request_ids =
|
||||||
HelpRequest
|
HelpRequest
|
||||||
|
|
@ -121,7 +131,12 @@ defmodule Mix.Tasks.Wnh.StagingE2e do
|
||||||
|> select([assignment], assignment.id)
|
|> select([assignment], assignment.id)
|
||||||
|> Repo.all()
|
|> 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} =
|
{:ok, deleted} =
|
||||||
Repo.transaction(fn ->
|
Repo.transaction(fn ->
|
||||||
|
|
@ -227,8 +242,8 @@ defmodule Mix.Tasks.Wnh.StagingE2e do
|
||||||
}
|
}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
unless deleted.users == 2 do
|
unless deleted.users == 3 do
|
||||||
Mix.raise("cleanup removed #{deleted.users} users instead of exactly 2")
|
Mix.raise("cleanup removed #{deleted.users} users instead of exactly 3")
|
||||||
end
|
end
|
||||||
|
|
||||||
Mix.shell().info("removed exact staging E2E fixture: #{inspect(deleted)}")
|
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["database"] == context.database and
|
||||||
manifest["requester_email"] == expected_emails.requester and
|
manifest["requester_email"] == expected_emails.requester and
|
||||||
manifest["helper_email"] == expected_emails.helper 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")
|
Mix.raise("staging E2E manifest does not match the requested run and database")
|
||||||
end
|
end
|
||||||
|
|
||||||
expected =
|
expected =
|
||||||
MapSet.new([
|
MapSet.new([
|
||||||
{manifest["requester_id"], manifest["requester_email"]},
|
{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 =
|
observed =
|
||||||
User
|
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})
|
|> select([user], {user.id, user.email})
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
|> MapSet.new()
|
|> MapSet.new()
|
||||||
|
|
@ -263,7 +288,7 @@ defmodule Mix.Tasks.Wnh.StagingE2e do
|
||||||
end
|
end
|
||||||
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? =
|
unexpected_request? =
|
||||||
Repo.exists?(
|
Repo.exists?(
|
||||||
from(request in HelpRequest,
|
from(request in HelpRequest,
|
||||||
|
|
@ -276,7 +301,7 @@ defmodule Mix.Tasks.Wnh.StagingE2e do
|
||||||
from(assignment in Assignment,
|
from(assignment in Assignment,
|
||||||
where:
|
where:
|
||||||
assignment.helper_id in ^user_ids and
|
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? =
|
unexpected_proposal? =
|
||||||
Repo.exists?(from(proposal in CategoryProposal, where: proposal.proposer_id in ^user_ids))
|
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_request? and not unexpected_assignment? and
|
||||||
not unexpected_activity? and not unexpected_proposal? do
|
not unexpected_activity? and not unexpected_proposal? do
|
||||||
Mix.raise("staging E2E users own records outside the exact medicine-flow scope")
|
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
|
defp emails(run_id) do
|
||||||
%{
|
%{
|
||||||
requester: "wnh-staging-e2e-#{run_id}-requester@example.invalid",
|
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
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ defmodule WhoNeedHelp.GoogleAuth.AssentAdapter do
|
||||||
@behaviour WhoNeedHelp.GoogleAuth
|
@behaviour WhoNeedHelp.GoogleAuth
|
||||||
|
|
||||||
alias Assent.Strategy.{Google, OIDC}
|
alias Assent.Strategy.{Google, OIDC}
|
||||||
|
alias WhoNeedHelp.GoogleAuth.CrossClientIdTokenVerifier
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def enabled?, do: not is_nil(provider_config())
|
def enabled?, do: not is_nil(provider_config())
|
||||||
|
|
@ -44,21 +45,40 @@ defmodule WhoNeedHelp.GoogleAuth.AssentAdapter do
|
||||||
@impl true
|
@impl true
|
||||||
def verify_id_token(id_token, nonce)
|
def verify_id_token(id_token, nonce)
|
||||||
when is_binary(id_token) and id_token != "" and is_binary(nonce) and nonce != "" do
|
when is_binary(id_token) and id_token != "" and is_binary(nonce) and nonce != "" do
|
||||||
with config when is_list(config) <- provider_config(),
|
case provider_config() do
|
||||||
{:ok, jwt} <-
|
config when is_list(config) ->
|
||||||
config
|
config = Keyword.put(config, :session_params, %{nonce: nonce})
|
||||||
|> Keyword.put(:session_params, %{nonce: nonce})
|
|
||||||
|> OIDC.validate_id_token(id_token) do
|
case OIDC.validate_id_token(config, id_token) do
|
||||||
|
{:ok, jwt} ->
|
||||||
normalize_identity(jwt.claims)
|
normalize_identity(jwt.claims)
|
||||||
else
|
|
||||||
nil -> {:error, :provider_disabled}
|
{:error, reason} = error ->
|
||||||
{:error, _reason} = error -> error
|
verify_cross_client_id_token(config, id_token, nonce, reason, error)
|
||||||
_invalid -> {:error, :invalid_id_token}
|
end
|
||||||
|
|
||||||
|
nil ->
|
||||||
|
{:error, :provider_disabled}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def verify_id_token(_id_token, _nonce), do: {:error, :invalid_id_token}
|
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(
|
def normalize_identity(
|
||||||
%{
|
%{
|
||||||
"sub" => provider_uid,
|
"sub" => provider_uid,
|
||||||
|
|
|
||||||
237
lib/who_need_help/google_auth/cross_client_id_token_verifier.ex
Normal file
237
lib/who_need_help/google_auth/cross_client_id_token_verifier.ex
Normal 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
|
||||||
|
|
@ -117,6 +117,19 @@ valid_sha256_fingerprint_list() {
|
||||||
done
|
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
|
failures=0
|
||||||
warnings=0
|
warnings=0
|
||||||
|
|
||||||
|
|
@ -196,6 +209,18 @@ else
|
||||||
partial "Google sign-in" "client ID and secret must be configured together"
|
partial "Google sign-in" "client ID and secret must be configured together"
|
||||||
fi
|
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
|
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"
|
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
|
elif all_set WEB_PUSH_VAPID_PUBLIC_KEY WEB_PUSH_VAPID_PRIVATE_KEY WEB_PUSH_VAPID_SUBJECT; then
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,7 @@ public_upstream_name=${PRODUCTION_PUBLIC_UPSTREAM_NAME:-who-need-help-production
|
||||||
codex_session_id=${PRODUCTION_CODEX_SESSION_ID:-}
|
codex_session_id=${PRODUCTION_CODEX_SESSION_ID:-}
|
||||||
google_oauth_client_id=${PRODUCTION_GOOGLE_OAUTH_CLIENT_ID:-}
|
google_oauth_client_id=${PRODUCTION_GOOGLE_OAUTH_CLIENT_ID:-}
|
||||||
google_oauth_client_secret=${PRODUCTION_GOOGLE_OAUTH_CLIENT_SECRET:-}
|
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_public_key=${PRODUCTION_WEB_PUSH_VAPID_PUBLIC_KEY:-}
|
||||||
web_push_vapid_private_key=${PRODUCTION_WEB_PUSH_VAPID_PRIVATE_KEY:-}
|
web_push_vapid_private_key=${PRODUCTION_WEB_PUSH_VAPID_PRIVATE_KEY:-}
|
||||||
web_push_vapid_subject=${PRODUCTION_WEB_PUSH_VAPID_SUBJECT:-}
|
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_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_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_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_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_PRIVATE_KEY "$web_push_vapid_private_key"
|
||||||
require_single_line_env_value PRODUCTION_WEB_PUSH_VAPID_SUBJECT "$web_push_vapid_subject"
|
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
|
echo "Production Google OAuth client ID and secret must either both be set or both be empty." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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 {
|
if {
|
||||||
[ -n "$android_app_links_package_name" ] ||
|
[ -n "$android_app_links_package_name" ] ||
|
||||||
|
|
@ -395,6 +409,7 @@ CODEX_SESSION_ID_VALUE=$codex_session_id \
|
||||||
GIT_SHA_VALUE=$git_sha \
|
GIT_SHA_VALUE=$git_sha \
|
||||||
GOOGLE_OAUTH_CLIENT_ID_VALUE=$google_oauth_client_id \
|
GOOGLE_OAUTH_CLIENT_ID_VALUE=$google_oauth_client_id \
|
||||||
GOOGLE_OAUTH_CLIENT_SECRET_VALUE=$google_oauth_client_secret \
|
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_PUBLIC_KEY_VALUE=$web_push_vapid_public_key \
|
||||||
WEB_PUSH_VAPID_PRIVATE_KEY_VALUE=$web_push_vapid_private_key \
|
WEB_PUSH_VAPID_PRIVATE_KEY_VALUE=$web_push_vapid_private_key \
|
||||||
WEB_PUSH_VAPID_SUBJECT_VALUE=$web_push_vapid_subject \
|
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["SUPPORT_INBOX_ADDRESS"] = ENVIRON["SUPPORT_INBOX_ADDRESS_VALUE"]
|
||||||
replacement["GOOGLE_OAUTH_CLIENT_ID"] = ENVIRON["GOOGLE_OAUTH_CLIENT_ID_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_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_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_PRIVATE_KEY"] = ENVIRON["WEB_PUSH_VAPID_PRIVATE_KEY_VALUE"]
|
||||||
replacement["WEB_PUSH_VAPID_SUBJECT"] = ENVIRON["WEB_PUSH_VAPID_SUBJECT_VALUE"]
|
replacement["WEB_PUSH_VAPID_SUBJECT"] = ENVIRON["WEB_PUSH_VAPID_SUBJECT_VALUE"]
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@ mailpit_port=${TEST_MAILPIT_PORT:-8027}
|
||||||
codex_session_id=${TEST_CODEX_SESSION_ID:-}
|
codex_session_id=${TEST_CODEX_SESSION_ID:-}
|
||||||
google_oauth_client_id=${TEST_GOOGLE_OAUTH_CLIENT_ID:-}
|
google_oauth_client_id=${TEST_GOOGLE_OAUTH_CLIENT_ID:-}
|
||||||
google_oauth_client_secret=${TEST_GOOGLE_OAUTH_CLIENT_SECRET:-}
|
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_public_key=${TEST_WEB_PUSH_VAPID_PUBLIC_KEY:-}
|
||||||
web_push_vapid_private_key=${TEST_WEB_PUSH_VAPID_PRIVATE_KEY:-}
|
web_push_vapid_private_key=${TEST_WEB_PUSH_VAPID_PRIVATE_KEY:-}
|
||||||
web_push_vapid_subject=${TEST_WEB_PUSH_VAPID_SUBJECT:-}
|
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_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_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_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_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_PRIVATE_KEY "$web_push_vapid_private_key"
|
||||||
require_single_line_env_value TEST_WEB_PUSH_VAPID_SUBJECT "$web_push_vapid_subject"
|
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
|
exit 1
|
||||||
}
|
}
|
||||||
fi
|
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
|
if [[ -n "$android_app_links_package_name" || -n "$android_app_links_fingerprints" ]]; then
|
||||||
[[ -n "$android_app_links_package_name" && -n "$android_app_links_fingerprints" ]] || {
|
[[ -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 \
|
METRICS_TOKEN_VALUE=$metrics_token \
|
||||||
GOOGLE_OAUTH_CLIENT_ID_VALUE=$google_oauth_client_id \
|
GOOGLE_OAUTH_CLIENT_ID_VALUE=$google_oauth_client_id \
|
||||||
GOOGLE_OAUTH_CLIENT_SECRET_VALUE=$google_oauth_client_secret \
|
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_PUBLIC_KEY_VALUE=$web_push_vapid_public_key \
|
||||||
WEB_PUSH_VAPID_PRIVATE_KEY_VALUE=$web_push_vapid_private_key \
|
WEB_PUSH_VAPID_PRIVATE_KEY_VALUE=$web_push_vapid_private_key \
|
||||||
WEB_PUSH_VAPID_SUBJECT_VALUE=$web_push_vapid_subject \
|
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["SUPPORT_INBOX_ADDRESS"] = ENVIRON["SUPPORT_INBOX_ADDRESS_VALUE"]
|
||||||
replacement["GOOGLE_OAUTH_CLIENT_ID"] = ENVIRON["GOOGLE_OAUTH_CLIENT_ID_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_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_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_PRIVATE_KEY"] = ENVIRON["WEB_PUSH_VAPID_PRIVATE_KEY_VALUE"]
|
||||||
replacement["WEB_PUSH_VAPID_SUBJECT"] = ENVIRON["WEB_PUSH_VAPID_SUBJECT_VALUE"]
|
replacement["WEB_PUSH_VAPID_SUBJECT"] = ENVIRON["WEB_PUSH_VAPID_SUBJECT_VALUE"]
|
||||||
|
|
|
||||||
|
|
@ -332,6 +332,8 @@ printf '%s\n' \
|
||||||
'PHX_SCHEME=https' \
|
'PHX_SCHEME=https' \
|
||||||
'PHX_URL_PORT=443' \
|
'PHX_URL_PORT=443' \
|
||||||
'WNH_BASE_URL=https://dev.help.test' \
|
'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_PACKAGE_NAME=org.whoneedhelp.mobile.development' \
|
||||||
"ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS=$android_fingerprint" \
|
"ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS=$android_fingerprint" \
|
||||||
'ANDROID_PLAY_APP_SIGNING_SHA256_CERT_FINGERPRINTS=' \
|
'ANDROID_PLAY_APP_SIGNING_SHA256_CERT_FINGERPRINTS=' \
|
||||||
|
|
@ -340,6 +342,16 @@ chmod 600 "$android_env"
|
||||||
./scripts/validate-android-environment.sh \
|
./scripts/validate-android-environment.sh \
|
||||||
"$android_env" development >/dev/null
|
"$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 \
|
sed -i \
|
||||||
's|^ANDROID_APP_LINKS_PACKAGE_NAME=.*|ANDROID_APP_LINKS_PACKAGE_NAME=org.whoneedhelp.mobile.staging|' \
|
's|^ANDROID_APP_LINKS_PACKAGE_NAME=.*|ANDROID_APP_LINKS_PACKAGE_NAME=org.whoneedhelp.mobile.staging|' \
|
||||||
"$android_env"
|
"$android_env"
|
||||||
|
|
@ -457,6 +469,7 @@ fi
|
||||||
TEST_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \
|
TEST_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \
|
||||||
TEST_GOOGLE_OAUTH_CLIENT_ID=quality-test-client \
|
TEST_GOOGLE_OAUTH_CLIENT_ID=quality-test-client \
|
||||||
TEST_GOOGLE_OAUTH_CLIENT_SECRET="quality-test\$secret" \
|
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_PUBLIC_KEY=quality-test-vapid-public \
|
||||||
TEST_WEB_PUSH_VAPID_PRIVATE_KEY=quality-test-vapid-private \
|
TEST_WEB_PUSH_VAPID_PRIVATE_KEY=quality-test-vapid-private \
|
||||||
TEST_WEB_PUSH_VAPID_SUBJECT=mailto:test@help.test \
|
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 '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_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_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 '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_APPLICATION_ID=1:123456789:android:quality-test' "$test_env" >/dev/null
|
||||||
grep -Fx 'WNH_FIREBASE_PROJECT_ID=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_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \
|
||||||
PRODUCTION_GOOGLE_OAUTH_CLIENT_ID=quality-production-client \
|
PRODUCTION_GOOGLE_OAUTH_CLIENT_ID=quality-production-client \
|
||||||
PRODUCTION_GOOGLE_OAUTH_CLIENT_SECRET=quality-production-secret \
|
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_PUBLIC_KEY=quality-vapid-public \
|
||||||
PRODUCTION_WEB_PUSH_VAPID_PRIVATE_KEY=quality-vapid-private \
|
PRODUCTION_WEB_PUSH_VAPID_PRIVATE_KEY=quality-vapid-private \
|
||||||
PRODUCTION_WEB_PUSH_VAPID_SUBJECT=mailto:contact@help.test \
|
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
|
./scripts/init-production-env.sh help.test "$production_env" >/dev/null
|
||||||
test "$(stat -c '%a' "$production_env")" = 600
|
test "$(stat -c '%a' "$production_env")" = 600
|
||||||
grep -Fx "SMTP_PASSWORD=quality\$\$password" "$production_env" >/dev/null
|
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/validate-production-env.sh "$production_env" help.test >/dev/null
|
||||||
./scripts/check-environment-readiness.sh "$production_env" --require-release >/dev/null
|
./scripts/check-environment-readiness.sh "$production_env" --require-release >/dev/null
|
||||||
if PRODUCTION_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \
|
if PRODUCTION_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,8 @@ phx_host=$(read_unique PHX_HOST)
|
||||||
phx_scheme=$(read_unique PHX_SCHEME)
|
phx_scheme=$(read_unique PHX_SCHEME)
|
||||||
phx_port=$(read_unique PHX_URL_PORT)
|
phx_port=$(read_unique PHX_URL_PORT)
|
||||||
base_url=$(read_unique WNH_BASE_URL)
|
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_package=$(read_unique ANDROID_APP_LINKS_PACKAGE_NAME)
|
||||||
app_links_fingerprints=$(read_unique ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS)
|
app_links_fingerprints=$(read_unique ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS)
|
||||||
play_app_signing_fingerprints=
|
play_app_signing_fingerprints=
|
||||||
|
|
@ -102,6 +104,22 @@ fi
|
||||||
echo "WNH_BASE_URL must equal the canonical PHX origin: $expected_origin" >&2
|
echo "WNH_BASE_URL must equal the canonical PHX origin: $expected_origin" >&2
|
||||||
exit 1
|
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"
|
IFS=',' read -r -a fingerprints <<<"$app_links_fingerprints"
|
||||||
for fingerprint in "${fingerprints[@]}"; do
|
for fingerprint in "${fingerprints[@]}"; do
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,21 @@ if [[ -n "$test_google_id" || -n "$production_google_id" ]]; then
|
||||||
}
|
}
|
||||||
fi
|
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)
|
test_email_provider=$(read_env "$test_env" EMAIL_DELIVERY_PROVIDER)
|
||||||
production_email_provider=$(read_env "$production_env" EMAIL_DELIVERY_PROVIDER)
|
production_email_provider=$(read_env "$production_env" EMAIL_DELIVERY_PROVIDER)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,19 @@ valid_fcm_service_account_json() {
|
||||||
' >/dev/null 2>&1
|
' >/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() {
|
reject_marker() {
|
||||||
local key=$1
|
local key=$1
|
||||||
local value=$2
|
local value=$2
|
||||||
|
|
@ -119,6 +132,7 @@ email_from_address=$(require_value EMAIL_FROM_ADDRESS)
|
||||||
support_inbox_address=$(optional_value SUPPORT_INBOX_ADDRESS)
|
support_inbox_address=$(optional_value SUPPORT_INBOX_ADDRESS)
|
||||||
google_oauth_client_id=$(optional_value GOOGLE_OAUTH_CLIENT_ID)
|
google_oauth_client_id=$(optional_value GOOGLE_OAUTH_CLIENT_ID)
|
||||||
google_oauth_client_secret=$(optional_value GOOGLE_OAUTH_CLIENT_SECRET)
|
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_public_key=$(optional_value WEB_PUSH_VAPID_PUBLIC_KEY)
|
||||||
web_push_vapid_private_key=$(optional_value WEB_PUSH_VAPID_PRIVATE_KEY)
|
web_push_vapid_private_key=$(optional_value WEB_PUSH_VAPID_PRIVATE_KEY)
|
||||||
web_push_vapid_subject=$(optional_value WEB_PUSH_VAPID_SUBJECT)
|
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_ID "$google_oauth_client_id"
|
||||||
reject_marker GOOGLE_OAUTH_CLIENT_SECRET "$google_oauth_client_secret"
|
reject_marker GOOGLE_OAUTH_CLIENT_SECRET "$google_oauth_client_secret"
|
||||||
fi
|
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_values=(
|
||||||
"$firebase_application_id"
|
"$firebase_application_id"
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,19 @@ valid_fcm_service_account_json() {
|
||||||
' >/dev/null 2>&1
|
' >/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 ]] || {
|
[[ "$(require_value DEPLOYMENT_ENV)" == test ]] || {
|
||||||
echo "Test validation requires DEPLOYMENT_ENV=test." >&2
|
echo "Test validation requires DEPLOYMENT_ENV=test." >&2
|
||||||
exit 1
|
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_id=$(read_value GOOGLE_OAUTH_CLIENT_ID 2>/dev/null || true)
|
||||||
google_secret=$(read_value GOOGLE_OAUTH_CLIENT_SECRET 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
|
if [[ -n "$google_id" || -n "$google_secret" ]]; then
|
||||||
[[ -n "$google_id" && -n "$google_secret" ]] || {
|
[[ -n "$google_id" && -n "$google_secret" ]] || {
|
||||||
echo "Test Google OAuth ID and secret must be configured together." >&2
|
echo "Test Google OAuth ID and secret must be configured together." >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
fi
|
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=(
|
firebase_values=(
|
||||||
"$(read_value WNH_FIREBASE_APPLICATION_ID 2>/dev/null || true)"
|
"$(read_value WNH_FIREBASE_APPLICATION_ID 2>/dev/null || true)"
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,25 @@ defmodule WhoNeedHelp.GoogleAuthTest do
|
||||||
|
|
||||||
alias WhoNeedHelp.GoogleAuth.AssentAdapter
|
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
|
test "Google authorization uses OIDC state, nonce, PKCE, and identity-only scopes" do
|
||||||
nonce = "session-bound-nonce"
|
nonce = "session-bound-nonce"
|
||||||
|
|
||||||
|
|
@ -145,10 +164,217 @@ defmodule WhoNeedHelp.GoogleAuthTest do
|
||||||
assert {:error, _reason} = AssentAdapter.verify_id_token(expired, nonce)
|
assert {:error, _reason} = AssentAdapter.verify_id_token(expired, nonce)
|
||||||
end
|
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
|
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
|
secret
|
||||||
|> JOSE.JWK.from_oct()
|
|> JOSE.JWK.from_oct()
|
||||||
|> JOSE.JWT.sign(%{"alg" => "HS256"}, claims)
|
|> JOSE.JWT.sign(%{"alg" => algorithm}, claims)
|
||||||
|> JOSE.JWS.compact()
|
|> JOSE.JWS.compact()
|
||||||
|> elem(1)
|
|> elem(1)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user