feat: add Google authentication and harden sign-in

This commit is contained in:
SimpleTest 2026-07-20 23:56:44 +03:00
parent a080c3e183
commit c4721e6702
46 changed files with 3996 additions and 1436 deletions

View File

@ -54,6 +54,11 @@ GITHUB_OAUTH_TOKEN_URL=
GITHUB_OAUTH_USER_URL=
GITHUB_OAUTH_HTTP_CONNECT_TIMEOUT_MS=
GITHUB_OAUTH_HTTP_RECEIVE_TIMEOUT_MS=
GOOGLE_OAUTH_CLIENT_ID=
GOOGLE_OAUTH_CLIENT_SECRET=
GOOGLE_OAUTH_BASE_URL=
GOOGLE_OAUTH_HTTP_CONNECT_TIMEOUT_MS=
GOOGLE_OAUTH_HTTP_RECEIVE_TIMEOUT_MS=
E2E_BASE_URL=https://proxy
E2E_MAILPIT_URL=http://mailpit:8025

View File

@ -52,6 +52,17 @@ GITHUB_OAUTH_USER_URL=
GITHUB_OAUTH_HTTP_CONNECT_TIMEOUT_MS=
GITHUB_OAUTH_HTTP_RECEIVE_TIMEOUT_MS=
# Optional Google OpenID Connect registration and sign-in. Leave both empty
# until a Google OAuth Web client exists. Its callback URL must be:
# https://YOUR_PHX_HOST/auth/google/callback
GOOGLE_OAUTH_CLIENT_ID=
GOOGLE_OAUTH_CLIENT_SECRET=
# 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=
GOOGLE_OAUTH_HTTP_CONNECT_TIMEOUT_MS=
GOOGLE_OAUTH_HTTP_RECEIVE_TIMEOUT_MS=
# Optional provider-neutral HTTP push boundary. Leave both endpoint and token
# empty to disable product push jobs. When enabled, all four numeric values are
# required deployment inputs; the project does not claim universal production

View File

@ -55,6 +55,11 @@ GITHUB_OAUTH_TOKEN_URL=
GITHUB_OAUTH_USER_URL=
GITHUB_OAUTH_HTTP_CONNECT_TIMEOUT_MS=
GITHUB_OAUTH_HTTP_RECEIVE_TIMEOUT_MS=
GOOGLE_OAUTH_CLIENT_ID=
GOOGLE_OAUTH_CLIENT_SECRET=
GOOGLE_OAUTH_BASE_URL=
GOOGLE_OAUTH_HTTP_CONNECT_TIMEOUT_MS=
GOOGLE_OAUTH_HTTP_RECEIVE_TIMEOUT_MS=
# These are reproducible experiment inputs, not capacity requirements,
# production traffic forecasts, alert thresholds, or recommended limits.

View File

@ -12,8 +12,9 @@ thank-you link; money goes directly between users outside the platform.
## What is implemented
- Phoenix 1.8 LiveView application with email magic-link/password auth and 18+
self-attestation.
- Phoenix 1.8 LiveView application with passwordless email magic links,
optional passwords, optional Google OpenID Connect registration/sign-in, and
18+ self-attestation.
- Data-driven, translated category tree with validated per-category fields,
seven selectable urgent/roadside scenarios, community proposals/votes, and
human approve/reject/merge tools.
@ -140,7 +141,7 @@ restores it into a new temporary database, checks corruption and interruption
failure paths, removes those temporary buckets, and retains the successful
encrypted bucket in local MinIO. It never writes a plaintext dump to the host.
Exercise the real OAuth/SMTP protocol clients and the provider-neutral push
Exercise the real GitHub OAuth, Google OIDC, SMTP protocol clients, and the provider-neutral push
boundary entirely inside an isolated Docker network:
```bash
@ -178,8 +179,9 @@ printing them, writes mode `0600`, and refuses to replace an existing file:
```
Configure the verified reverse-proxy source IP/CIDR and transactional SMTP
provider in that file, then validate its structure and the production Compose
render:
provider in that file. Email registration and magic-link login are unusable for
real recipients until that relay and its accepted sender are configured.
Then validate the file structure and the production Compose render:
```bash
./scripts/validate-production-env.sh .env.production whoneedhelp.com
@ -207,6 +209,39 @@ docker compose up -d --wait
This preserves the named PostgreSQL volume, but invalidates existing browser
sessions and changes handover codes for active local requests.
To rotate only the local PostgreSQL role and matching `DATABASE_URL`, without
changing Phoenix sessions or handover codes, use:
```bash
./scripts/rotate-local-secrets.sh --database-only
docker compose up -d --wait
```
### Optional Google registration and sign-in
Create a Google OAuth 2.0 Web application only for an owned HTTPS origin. Add
this exact authorized redirect URI in Google Cloud:
```text
https://YOUR_PHX_HOST/auth/google/callback
```
Set both `GOOGLE_OAUTH_CLIENT_ID` and `GOOGLE_OAUTH_CLIENT_SECRET` in the
ignored `.env` or deployment Secret, then recreate the application containers.
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.
The flow requests `openid email profile`, verifies the provider email claim,
uses state, nonce, and PKCE, and discards provider tokens. Google registration
creates a confirmed local account only after the user accepts the 18+ safety
terms. Google login works only for an identity already linked to that local
account. An existing local account is never merged merely because Google
returns the same email; sign in by email or password and connect Google from
the sudo-protected account settings page instead. Leave
`GOOGLE_OAUTH_BASE_URL` and the Google HTTP timeout variables empty outside the
isolated protocol drill.
### Optional verified GitHub linking
Create a GitHub OAuth App with this exact callback URL for the active public

View File

@ -17,6 +17,11 @@ x-boundary-app-environment: &boundary-app-environment
ALLOW_INSECURE_EXTERNAL_HTTP: "true"
GITHUB_OAUTH_HTTP_CONNECT_TIMEOUT_MS: "100"
GITHUB_OAUTH_HTTP_RECEIVE_TIMEOUT_MS: "100"
GOOGLE_OAUTH_CLIENT_ID: ${EXTERNAL_OAUTH_CLIENT_ID:?Set EXTERNAL_OAUTH_CLIENT_ID}
GOOGLE_OAUTH_CLIENT_SECRET: ${EXTERNAL_OAUTH_CLIENT_SECRET:?Set EXTERNAL_OAUTH_CLIENT_SECRET}
GOOGLE_OAUTH_BASE_URL: http://external-mock:8080
GOOGLE_OAUTH_HTTP_CONNECT_TIMEOUT_MS: "100"
GOOGLE_OAUTH_HTTP_RECEIVE_TIMEOUT_MS: "100"
SMTP_RELAY: external-mock
SMTP_PORT: "2525"
SMTP_USERNAME: ""

View File

@ -37,6 +37,11 @@ x-app-environment: &app-environment
GITHUB_OAUTH_USER_URL: ${GITHUB_OAUTH_USER_URL:-}
GITHUB_OAUTH_HTTP_CONNECT_TIMEOUT_MS: ${GITHUB_OAUTH_HTTP_CONNECT_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_SECRET: ${GOOGLE_OAUTH_CLIENT_SECRET:-}
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:-}
PUSH_HTTP_ENDPOINT: ${PUSH_HTTP_ENDPOINT:-}
PUSH_HTTP_BEARER_TOKEN: ${PUSH_HTTP_BEARER_TOKEN:-}
PUSH_HTTP_MAX_ATTEMPTS: ${PUSH_HTTP_MAX_ATTEMPTS:-}

View File

@ -133,20 +133,21 @@ oauth_endpoint = fn name, default ->
end
end
oauth_http_options =
oauth_http_options = fn prefix ->
[retry: false]
|> then(fn options ->
case optional_positive_integer.("GITHUB_OAUTH_HTTP_RECEIVE_TIMEOUT_MS") do
case optional_positive_integer.("#{prefix}_OAUTH_HTTP_RECEIVE_TIMEOUT_MS") do
nil -> options
timeout -> Keyword.put(options, :receive_timeout, timeout)
end
end)
|> then(fn options ->
case optional_positive_integer.("GITHUB_OAUTH_HTTP_CONNECT_TIMEOUT_MS") do
case optional_positive_integer.("#{prefix}_OAUTH_HTTP_CONNECT_TIMEOUT_MS") do
nil -> options
timeout -> Keyword.put(options, :connect_options, timeout: timeout)
end
end)
end
github_oauth =
case {
@ -172,7 +173,7 @@ github_oauth =
"https://github.com/login/oauth/access_token"
),
user_url: oauth_endpoint.("GITHUB_OAUTH_USER_URL", "https://api.github.com/user"),
http_adapter: {Assent.HTTPAdapter.Req, oauth_http_options}
http_adapter: {Assent.HTTPAdapter.Req, oauth_http_options.("GITHUB")}
]
}
@ -187,6 +188,33 @@ github_oauth =
config :who_need_help, :social_oauth, github_oauth
google_auth =
case {
System.get_env("GOOGLE_OAUTH_CLIENT_ID"),
System.get_env("GOOGLE_OAUTH_CLIENT_SECRET")
} do
{client_id, client_secret}
when is_binary(client_id) and client_id != "" and is_binary(client_secret) and
client_secret != "" ->
[
client_id: client_id,
client_secret: client_secret,
base_url: oauth_endpoint.("GOOGLE_OAUTH_BASE_URL", "https://accounts.google.com/"),
authorization_params: [scope: "email profile"],
http_adapter: {Assent.HTTPAdapter.Req, oauth_http_options.("GOOGLE")}
]
{client_id, client_secret} when client_id in [nil, ""] and client_secret in [nil, ""] ->
[]
_partial_configuration ->
raise """
GOOGLE_OAUTH_CLIENT_ID and GOOGLE_OAUTH_CLIENT_SECRET must either both be set or both be empty.
"""
end
config :who_need_help, :google_auth, google_auth
push_configuration =
case {
System.get_env("PUSH_HTTP_ENDPOINT"),
@ -366,6 +394,23 @@ if config_env() == :prod do
other -> raise "SMTP_SSL must be true, false, 1, or 0; got #{inspect(other)}"
end
smtp_username = System.get_env("SMTP_USERNAME")
smtp_password = System.get_env("SMTP_PASSWORD")
smtp_username_set? = is_binary(smtp_username) and smtp_username != ""
smtp_password_set? = is_binary(smtp_password) and smtp_password != ""
if smtp_username_set? != smtp_password_set? do
raise "SMTP_USERNAME and SMTP_PASSWORD must either both be set or both be empty."
end
if smtp_auth == :always and not (smtp_username_set? and smtp_password_set?) do
raise "SMTP_USERNAME and SMTP_PASSWORD are required when SMTP_AUTH is always."
end
if smtp_ssl and smtp_tls != :never do
raise "SMTP_TLS must be never when SMTP_SSL enables an implicit TLS connection."
end
smtp_config =
[
adapter: Swoosh.Adapters.SMTP,
@ -376,13 +421,13 @@ if config_env() == :prod do
ssl: smtp_ssl
]
|> then(fn config ->
case System.get_env("SMTP_USERNAME") do
case smtp_username do
value when is_binary(value) and value != "" -> Keyword.put(config, :username, value)
_ -> config
end
end)
|> then(fn config ->
case System.get_env("SMTP_PASSWORD") do
case smtp_password do
value when is_binary(value) and value != "" -> Keyword.put(config, :password, value)
_ -> config
end

View File

@ -29,6 +29,7 @@ config :who_need_help, WhoNeedHelpWeb.Endpoint,
# In test we don't send emails
config :who_need_help, WhoNeedHelp.Mailer, adapter: Swoosh.Adapters.Test
config :who_need_help, :social_oauth_adapter, WhoNeedHelp.SocialOAuthFake
config :who_need_help, :google_auth_adapter, WhoNeedHelp.GoogleAuthFake
config :who_need_help, :metrics_token, "test-metrics-token"
# Disable swoosh api client as it is only required for production adapters

View File

@ -39,7 +39,9 @@ app:
# Required. The Secret must contain DATABASE_URL, SECRET_KEY_BASE,
# HANDOVER_SECRET, RELEASE_COOKIE, and METRICS_TOKEN. It may also contain both
# GITHUB_OAUTH_CLIENT_ID and GITHUB_OAUTH_CLIENT_SECRET to enable verified
# GitHub linking. Push is opt-in: provide PUSH_HTTP_ENDPOINT,
# GitHub linking, and both GOOGLE_OAUTH_CLIENT_ID and
# GOOGLE_OAUTH_CLIENT_SECRET to enable Google registration/sign-in. Push is
# opt-in: provide PUSH_HTTP_ENDPOINT,
# PUSH_HTTP_BEARER_TOKEN, PUSH_HTTP_MAX_ATTEMPTS,
# PUSH_HTTP_RECEIVE_TIMEOUT_MS, PUSH_HTTP_CONNECT_TIMEOUT_MS, and
# PUSH_HTTP_RETRY_DELAY_MS as one complete set.

View File

@ -47,8 +47,13 @@ and are not represented as complete.
## Application boundaries
- `Accounts`: users, authentication, social identities, privacy preferences,
blocks, and roles.
- `Accounts`: users, email/password authentication, private authentication
identities, public social identities, privacy preferences, blocks, and
roles.
- `GoogleAuth`: optional Google OpenID Connect boundary for registration,
sign-in, and account linking. It uses state, nonce, and PKCE, returns only a
normalized verified-email identity, and does not expose provider tokens to
controllers or persistence.
- `SocialOAuth`: optional provider boundary for verified social linking. The
GitHub adapter uses OAuth state and PKCE, returns only normalized identity
attributes to the controller, and does not expose the provider token to the

View File

@ -59,7 +59,10 @@ do not copy the temporary VPN value into an unrelated server.
Configure the transactional SMTP relay and sender accepted by that provider.
Provider-specific auth, TLS, ports, and credentials can be supplied to the
initializer through the documented `PRODUCTION_SMTP_*` environment values or
edited in the resulting ignored file. Then run:
edited in the resulting ignored file. If Google registration/sign-in is
enabled, also set both Google Web client credentials and register
`https://YOUR_PHX_HOST/auth/google/callback` as the exact authorized redirect
URI. Leave both credentials empty to keep the feature disabled. Then run:
```bash
./scripts/validate-production-env.sh .env.production whoneedhelp.com
@ -73,8 +76,9 @@ docker compose \
The production override keeps Mailpit behind its inactive `local-mail` profile,
so public registration cannot appear to succeed while mail is only retained
locally. The validator checks file ownership/mode, origin consistency,
template markers, independent generated secrets, supported SMTP values, the
generated database URL, and the final Compose render without printing secrets.
template markers, independent generated secrets, supported SMTP values, a
complete-or-empty Google credential pair, the generated database URL, and the
final Compose render without printing secrets.
It does not contact DNS, TLS, SMTP, the reverse proxy, or the application.
After deployment, verify `/healthz/ready`, inspect all replica health and logs,
register a unique address through the public browser, receive its message at

View File

@ -38,7 +38,8 @@ helper per request. Every state transition is authorized and recorded.
## MVP scope
- Email magic-link/password authentication.
- Passwordless email magic-link authentication, optional passwords, and
optional Google OpenID Connect registration/sign-in.
- English default interface plus Ukrainian and Russian translations.
- Medicine pickup/delivery requests.
- Safe, non-emergency fuel delivery, car-wheel help, bicycle and motorcycle
@ -77,8 +78,9 @@ helper per request. Every state transition is authorized and recorded.
- Multiple simultaneous helpers on one request.
- Production-signed Android release, store publication, and native iOS
application.
- OAuth verification for providers other than GitHub and automated
social-network identity scoring.
- OAuth verification of public social-profile badges for providers other than
GitHub, and automated social-network identity scoring. Google authentication
is a private sign-in identity, not a public verified social-profile badge.
- Automatic punitive abuse enforcement and unapproved production thresholds.
- Claims that identity, safety, or fraud prevention is perfect.
@ -96,6 +98,12 @@ configured GitHub OAuth flow can set `verified_at` after provider authorization;
one provider account cannot be linked to two local accounts. The provider
access token is discarded rather than stored.
Google OpenID Connect identities are stored separately from public social
links. A verified Google email can create a confirmed local account after the
same 18+ safety acknowledgement, or can be connected from a recently
reauthenticated account-settings session. Matching email addresses never merge
accounts automatically, and Google provider tokens are not persisted.
## Privacy settings
Every user chooses one location visibility level:

View File

@ -16,16 +16,38 @@ results from product limits and unknown production properties.
| Consent-driven live tracking | Implemented and cross-client verified | On API 37, Android started `TrackingService` as a location foreground service with a persistent Stop notification. After Home minimized the Activity, an emulator coordinate change reached PostGIS. Notification Stop removed the service, notification, active session, and raw position. | Browsers stop with the page. Android has no `ACCESS_BACKGROUND_LOCATION`, unattended start, or route history. |
| Privacy settings | Implemented and browser-verified | The profile exposed hidden, approximate public, exact for active match, and explicit exact-public options. Blocking and current-position cleanup have automated tests. | Exact public location remains a user opt-in; legal privacy and retention text still requires jurisdiction-specific review before launch. |
| Reputation and anti-abuse | Implemented at MVP level | Handover codes, two-party completion, double-blind reviews, unique-counterpart ranking, optional movement/proximity evidence, reports, blocks, abuse signals, and moderator audit paths have automated tests. | The system is not bot-proof and does not claim identity verification. No punitive numeric policy is enabled without measured and approved thresholds. |
| Social profiles | Manual links implemented; optional GitHub verification implemented and automated-tested | Manual links cannot set verification fields. The optional GitHub flow uses state, PKCE, a user-bound one-time session, unique provider ownership, and an audit record; the current 186-test suite includes callback replay/state checks. The local protocol drill also performs real HTTP token/user exchanges without returning an access token to the application. | GitHub OAuth credentials are intentionally absent and are not required for registration or the help flow. The real external provider redirect/callback remains disabled and unverified; other providers remain manual/unverified. |
| Account registration and sign-in | Implemented and browser-verified | Email registration sends a confirmation magic link and does not require a password. Confirmed users can keep using magic links or add a password in settings. Google OpenID Connect registration, sign-in, link, unlink, replay prevention, verified-email enforcement, and account-ownership rules are covered by the 260-test suite. A headed Chrome run completed registration, confirmation through Mailpit, password setup, logout, password login, and localized settings with zero console warnings or errors. | The local Compose environment captures email in Mailpit. A production SMTP relay and a real Google Web client are not configured or externally verified. |
| Social profiles | Manual links implemented; optional GitHub verification implemented and automated-tested | Manual links cannot set verification fields. The optional GitHub flow uses state, PKCE, a user-bound one-time session, unique provider ownership, and an audit record. The local protocol drill also performs real HTTP token/user exchanges without returning an access token to the application. | GitHub OAuth credentials are intentionally absent and are not required for registration or the help flow. The real external provider redirect/callback remains disabled and unverified; other providers remain manual/unverified. |
| Voluntary thanks | Implemented as an external optional link | A helper can expose an optional link after completion; the UI states that the platform does not process the payment. | The platform does not provide payments, escrow, refunds, tax reporting, or payment guarantees. |
| Android client | Local and public-staging clients implemented and emulator-verified | The native packages `org.whoneedhelp.mobile.debug` and `org.whoneedhelp.mobile.staging` launch the same authenticated LiveView app. Seven lifecycle, permission, deep-link, foreground tracking, recoverable main-page failure, notification-Stop, and Activity-destruction tests passed on each of API 30, 34, and 37. The API 37 staging smoke asserted the public home and Safety DOM over HTTPS. A run-scoped Android/browser staging test passed login, private chat in both directions, foreground tracking, live marker appearance/removal, and exact cleanup. | Production signing, Play Store publication, verified Android App Links, unattended/background-permission tracking, and iOS are not implemented. |
| Multiple web/worker instances | Implemented and locally failure/rollout-verified | The final isolated Compose drill passed BEAM crashes and sequential replacement with 3 web/2 worker replicas: all five nodes joined, PubSub passed, and 744/744 readiness requests succeeded. The project-owned kind cluster replaced all 2 web/2 worker pod UIDs under `maxUnavailable=0`; all four replacement pods joined, PubSub passed, and 363/363 samples ultimately succeeded. | Local PostGIS is a single instance. Production database HA, backups, and recovery are operator work and are not claimed complete. |
| Local observability | Implemented and protocol-verified | Pinned Prometheus scraped the exact 3 web and 2 worker targets with a file Bearer credential; Grafana provisioned a healthy datasource and ten-panel web/worker/BEAM/Ecto/Oban dashboard; Alertmanager delivered firing and resolved webhooks for an induced scoped replica stop. | Local delivery does not establish production retention, notification-provider reliability, on-call policy, or measured alert thresholds. |
| Encrypted local backup | Implemented and failure-verified | Pinned Restic streamed PostgreSQL custom format into pinned local MinIO with no host plaintext dump, passed full-data checking and a fresh-database restore, rejected a corrupted repository, and published no snapshot for an interrupted upload. The one-run MinIO project and volume were removed after retaining the non-secret evidence. | The drill proves the local mechanism, not off-site durability, database HA, or a production RPO/RTO/retention policy. |
| External protocol boundaries | Implemented and locally failure-verified | The production release used its configured Assent/Req and Swoosh/gen_smtp clients against an internal-only mock; OAuth and SMTP success/rejection/retry/replay/timeout paths passed. The HTTP push boundary passed disabled, retry, rejection, timeout, and idempotency paths. Request acceptance and new-chat transactions created durable jobs processed by two Oban worker replicas; the chat event completed on Oban attempt 2 after an injected temporary failure. | This does not verify external provider availability or device delivery. FCM/APNs token registration and provider selection remain external work; SMTP exactly-once delivery is not claimed. |
| External protocol boundaries | Implemented and locally failure-verified | The production release used its configured Assent/Req and Swoosh/gen_smtp clients against internal-only mocks. GitHub OAuth, Google OIDC discovery/authorization/token/JWKS with nonce and PKCE, and SMTP success/rejection/retry/replay/timeout paths passed. The HTTP push boundary passed disabled, retry, rejection, timeout, and idempotency paths. Request acceptance and new-chat transactions created durable jobs processed by two Oban worker replicas; the chat event completed on Oban attempt 2 after an injected temporary failure. | This does not verify real external provider availability or device delivery. Google/GitHub production clients, production SMTP, FCM/APNs token registration, and provider selection remain external work; SMTP exactly-once delivery is not claimed. |
## Reproducible checks
- On 2026-07-20, after adding Google OpenID Connect authentication,
`./scripts/test.sh` and `mix precommit` each passed 260 tests with zero
failures. The full isolated `./scripts/quality.sh` gate also passed format,
compilation with warnings as errors, xref, Credo, Sobelow, Dialyzer, Hex and
npm audits, Compose/Helm rendering, environment validation, and configured
HIGH/CRITICAL source/image scans.
- The Google boundary drill used the production Assent adapter against a local
OIDC server with discovery, authorization, token, signed ID token, JWKS,
state, nonce, PKCE, unverified-email rejection, and one-time callback replay
checks. It passed together with GitHub OAuth, SMTP, push, and two-worker Oban
paths. Evidence is retained at
`output/external-boundaries/google-auth-fixed-20260720`.
- Headed Chrome verified the public temporary HTTPS origin through the
email-only registration form, Mailpit confirmation link, one-time login,
password creation, logout, password login, Russian locale selection, and
Google connection settings. The configured Google credential pair is empty,
so the UI correctly left Google actions disabled. Browser console inspection
reported zero errors and zero warnings. The run-owned account and its one
cascading login token were removed after read-only relationship checks; no
help, activity, message, tracking, review, or OAuth rows belonged to it. The
browser was left open.
- The isolated Phoenix suite completed on 2026-07-19 with 172
tests and 0 failures after cursor pagination, database aggregation, and the
full localization changes
@ -242,6 +264,14 @@ decoded lengths were inspected: the PostgreSQL password is 64 characters and
each application secret is 128 characters. Secret values were not printed or
written to tracked files.
Before the local `auth_identities` migration, a PostgreSQL custom-format dump
was created at `output/backups/compose-20260720-203913.dump`; its SHA-256 is
`ed8d663b114f3823479198005857af5501a90dacbec3f0855296327ee9cd6d20`.
An isolated restore drill read 23 public tables, 1,488 rows, 12 applied
migrations, and PostGIS 3.6.4 from that dump, then removed the temporary
database. The source Compose database subsequently reported 13 applied
migrations and an empty `auth_identities` table before browser verification.
## Dependency-upgrade observations
- The running Compose and kind releases reported Elixir 1.20.2 and Erlang/OTP
@ -1031,6 +1061,10 @@ is `output/external-boundaries/preprod-gitea-pass`.
and the availability model selected for real usage.
- Configure a real transactional email provider before public registration.
The current Compose mail path terminates at local Mailpit.
- Create a Google OAuth Web client for the final owned HTTPS origin, configure
its exact `/auth/google/callback` redirect, and exercise registration,
sign-in, and settings linking against Google if Google authentication is to
be enabled. The feature remains disabled when both credentials are empty.
- Configure and verify a real mobile push provider and device-token lifecycle
if native push is required. The provider-neutral HTTP boundary and product
jobs are tested; FCM/APNs device delivery is not.

View File

@ -315,12 +315,15 @@ export async function registerAndConfirm(
const page = await context.newPage();
await gotoWithTransientRetry(page, "/users/register");
await page.getByLabel("Email").fill(email);
await page.getByLabel("Display name").fill(displayName);
await page
const form = page.locator("#email_registration_form");
await form.getByLabel("Email").fill(email);
await form.getByLabel("Display name").fill(displayName);
await form
.getByLabel("I am 18 or older and accept the safety rules")
.check();
await page.getByRole("button", { name: "Create an account" }).click();
await form
.getByRole("button", { name: "Create account and email me a link" })
.click();
await expect(page).toHaveURL(/\/users\/log-in$/);
await expect(
page
@ -352,7 +355,7 @@ export async function loginWithMagicLink(
await gotoWithTransientRetry(page, "/users/log-in");
const form = page.locator("#login_form_magic");
await form.getByLabel("Email").fill(email);
await form.getByRole("button", { name: "Log in with email" }).click();
await form.getByRole("button", { name: "Email me a sign-in link" }).click();
await expect(
page.getByText(
"If your email is in our system, you will receive instructions for logging in shortly.",

View File

@ -7,7 +7,14 @@ defmodule WhoNeedHelp.Accounts do
alias WhoNeedHelp.Pagination
alias WhoNeedHelp.Repo
alias WhoNeedHelp.Accounts.{Scope, SocialIdentity, User, UserToken, UserNotifier}
alias WhoNeedHelp.Accounts.{
AuthIdentity,
Scope,
SocialIdentity,
User,
UserNotifier,
UserToken
}
## Database getters
@ -21,6 +28,14 @@ defmodule WhoNeedHelp.Accounts do
:user_id,
:inserted_at
]
@registration_key_atoms %{
"display_name" => :display_name,
"email" => :email,
"email_verified" => :email_verified,
"locale" => :locale,
"provider_uid" => :provider_uid,
"terms_accepted" => :terms_accepted
}
@doc """
Returns the deliberately small user projection used by public and
@ -340,6 +355,260 @@ defmodule WhoNeedHelp.Accounts do
end
end
## Authentication identities
def google_auth_connected?(%User{id: user_id}) do
Repo.exists?(
from identity in AuthIdentity,
where: identity.user_id == ^user_id and identity.provider == :google
)
end
def login_user_by_google(identity_attrs) do
with {:ok, identity_attrs} <- normalize_google_identity(identity_attrs) do
Repo.transact(fn ->
identity =
AuthIdentity
|> where(
[identity],
identity.provider == :google and
identity.provider_uid == ^identity_attrs.provider_uid
)
|> lock("FOR UPDATE")
|> Repo.one()
case identity do
nil ->
{:error, :not_linked}
%AuthIdentity{} = identity ->
with %User{} = user <-
User
|> where([user], user.id == ^identity.user_id)
|> lock("FOR UPDATE")
|> Repo.one(),
true <- user.moderation_status != :suspended,
{:ok, _identity} <-
identity
|> AuthIdentity.changeset(%{email: identity_attrs.email})
|> Repo.update() do
{:ok, user}
else
_missing_or_suspended -> {:error, :not_found}
end
end
end)
end
end
def register_user_by_google(identity_attrs, registration_attrs)
when is_map(registration_attrs) do
with {:ok, identity_attrs} <- normalize_google_identity(identity_attrs) do
Repo.transact(fn ->
identity =
AuthIdentity
|> where(
[identity],
identity.provider == :google and
identity.provider_uid == ^identity_attrs.provider_uid
)
|> lock("FOR UPDATE")
|> Repo.one()
case identity do
%AuthIdentity{} = identity ->
google_identity_user(identity, identity_attrs.email)
nil ->
register_google_identity(identity_attrs, registration_attrs)
end
end)
end
end
def register_user_by_google(_identity_attrs, _registration_attrs),
do: {:error, :invalid_registration}
def link_google_identity(%User{id: user_id}, identity_attrs) do
with {:ok, identity_attrs} <- normalize_google_identity(identity_attrs) do
Repo.transact(fn ->
user =
User
|> where([user], user.id == ^user_id)
|> lock("FOR UPDATE")
|> Repo.one()
cond do
is_nil(user) or user.moderation_status == :suspended ->
{:error, :not_found}
true ->
upsert_google_identity_for_user(user, identity_attrs)
end
end)
end
end
def unlink_google_identity(%User{id: user_id}) do
Repo.transact(fn ->
identity =
AuthIdentity
|> where(
[identity],
identity.provider == :google and identity.user_id == ^user_id
)
|> lock("FOR UPDATE")
|> Repo.one()
case identity do
%AuthIdentity{} = identity -> Repo.delete(identity)
nil -> {:error, :not_found}
end
end)
end
defp register_google_identity(identity_attrs, registration_attrs) do
existing_user =
User
|> where([user], user.email == ^identity_attrs.email)
|> lock("FOR UPDATE")
|> Repo.one()
if existing_user do
{:error, :email_already_registered}
else
attrs = %{
"email" => identity_attrs.email,
"display_name" => identity_attrs.display_name,
"locale" => registration_value(registration_attrs, "locale", "en"),
"terms_accepted" => registration_value(registration_attrs, "terms_accepted", false)
}
with {:ok, user} <- register_user(attrs),
{:ok, user} <- user |> User.confirm_changeset() |> Repo.update(),
{:ok, identity} <-
%AuthIdentity{}
|> AuthIdentity.changeset(%{
provider: :google,
provider_uid: identity_attrs.provider_uid,
email: identity_attrs.email,
user_id: user.id
})
|> Repo.insert() do
{:ok, {user, identity}}
end
end
end
defp google_identity_user(identity, current_email) do
user =
User
|> where([user], user.id == ^identity.user_id)
|> lock("FOR UPDATE")
|> Repo.one()
with %User{} = user <- user,
true <- user.moderation_status != :suspended,
{:ok, identity} <-
identity
|> AuthIdentity.changeset(%{email: current_email})
|> Repo.update() do
{:ok, {user, identity}}
else
_missing_or_suspended -> {:error, :not_found}
end
end
defp upsert_google_identity_for_user(user, identity_attrs) do
provider_identity =
AuthIdentity
|> where(
[identity],
identity.provider == :google and
identity.provider_uid == ^identity_attrs.provider_uid
)
|> lock("FOR UPDATE")
|> Repo.one()
user_identity =
AuthIdentity
|> where(
[identity],
identity.provider == :google and identity.user_id == ^user.id
)
|> lock("FOR UPDATE")
|> Repo.one()
cond do
provider_identity && provider_identity.user_id != user.id ->
{:error, :already_linked}
user_identity && user_identity.provider_uid != identity_attrs.provider_uid ->
{:error, :provider_already_linked}
identity = provider_identity || user_identity ->
identity
|> AuthIdentity.changeset(%{email: identity_attrs.email})
|> Repo.update()
true ->
%AuthIdentity{}
|> AuthIdentity.changeset(%{
provider: :google,
provider_uid: identity_attrs.provider_uid,
email: identity_attrs.email,
user_id: user.id
})
|> Repo.insert()
end
end
defp normalize_google_identity(identity_attrs) when is_map(identity_attrs) do
provider_uid = registration_value(identity_attrs, "provider_uid", nil)
email = registration_value(identity_attrs, "email", nil)
email_verified = registration_value(identity_attrs, "email_verified", false)
display_name = registration_value(identity_attrs, "display_name", nil)
cond do
email_verified != true ->
{:error, :email_not_verified}
not is_binary(provider_uid) or provider_uid == "" or byte_size(provider_uid) > 255 ->
{:error, :invalid_provider_identity}
not is_binary(email) or email == "" or byte_size(email) > 160 ->
{:error, :invalid_provider_identity}
not is_binary(display_name) or String.trim(display_name) == "" ->
{:error, :invalid_provider_identity}
true ->
{:ok,
%{
provider_uid: provider_uid,
email: email |> String.trim() |> String.downcase(),
email_verified: true,
display_name: display_name |> String.trim() |> String.slice(0, 80)
}}
end
end
defp normalize_google_identity(_identity_attrs),
do: {:error, :invalid_provider_identity}
defp registration_value(attrs, key, default) do
case Map.fetch(attrs, key) do
{:ok, value} ->
value
:error ->
case Map.fetch(@registration_key_atoms, key) do
{:ok, atom_key} -> Map.get(attrs, atom_key, default)
:error -> default
end
end
end
## Settings
@doc """

View File

@ -0,0 +1,36 @@
defmodule WhoNeedHelp.Accounts.AuthIdentity do
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "auth_identities" do
field :provider, Ecto.Enum, values: [:google]
field :provider_uid, :string
field :email, :string
belongs_to :user, WhoNeedHelp.Accounts.User
timestamps(type: :utc_datetime)
end
def changeset(identity, attrs) do
identity
|> cast(attrs, [:provider, :provider_uid, :email, :user_id])
|> update_change(:email, &normalize_email/1)
|> validate_required([:provider, :provider_uid, :email, :user_id])
|> validate_inclusion(:provider, [:google])
|> validate_length(:provider_uid, min: 1, max: 255)
|> validate_format(:email, ~r/^[^@,;\s]+@[^@,;\s]+$/,
message: "must have the @ sign and no spaces"
)
|> validate_length(:email, max: 160)
|> unique_constraint([:provider, :provider_uid])
|> unique_constraint([:user_id, :provider])
end
defp normalize_email(email) when is_binary(email),
do: email |> String.trim() |> String.downcase()
defp normalize_email(email), do: email
end

View File

@ -17,9 +17,11 @@ defmodule WhoNeedHelp.ExternalBoundaryDrill do
alias WhoNeedHelp.Push.DisabledAdapter, as: PushDisabledAdapter
alias WhoNeedHelp.Push.HTTPAdapter, as: PushHTTPAdapter
alias WhoNeedHelp.Repo
alias WhoNeedHelp.GoogleAuth.AssentAdapter, as: GoogleAssentAdapter
alias WhoNeedHelp.SocialOAuth.AssentAdapter
@oauth_redirect_uri "http://boundary.local/auth/social/github/callback"
@google_redirect_uri "http://boundary.local/auth/google/callback"
@output_directory "/output"
@output_path "/output/summary.json"
@ -36,6 +38,7 @@ defmodule WhoNeedHelp.ExternalBoundaryDrill do
summary = %{
status: "passed",
oauth: oauth_drill(base_url),
google_oidc: google_oidc_drill(base_url),
smtp: smtp_drill(base_url),
push: Map.put(protocol_push, :domain_workflow_integration, product_push.status),
push_product_integration: product_push
@ -49,6 +52,118 @@ defmodule WhoNeedHelp.ExternalBoundaryDrill do
:ok
end
defp google_oidc_drill(base_url) do
assert!(GoogleAssentAdapter.enabled?(), "runtime Google OIDC config is disabled")
control!(base_url, "google", "success")
{success_session, success_params} = google_authorization!()
{:ok, identity} =
GoogleAssentAdapter.callback(
@google_redirect_uri,
success_params,
success_session
)
assert!(
identity.provider_uid == "google-local-subject",
"Google subject was not normalized"
)
assert!(
identity.email == "google-helper@example.invalid",
"Google email was not normalized"
)
assert!(
not Map.has_key?(identity, :access_token),
"Google access token escaped adapter boundary"
)
success_state = state!(base_url)["google"]
assert!(success_state["token_requests"] == 1, "Google token request was not observed")
assert!(success_state["jwks_requests"] == 1, "Google JWKS request was not observed")
assert!(success_state["consumed_codes"] == 1, "Google code was not consumed")
assert_error!(
GoogleAssentAdapter.callback(
@google_redirect_uri,
success_params,
success_session
),
"Google authorization code replay unexpectedly succeeded"
)
replay_state = state!(base_url)["google"]
assert!(replay_state["token_requests"] == 2, "Google replay did not reach token endpoint")
assert!(replay_state["jwks_requests"] == 1, "Google replay reached the JWKS endpoint")
control!(base_url, "google", "success")
{mismatch_session, mismatch_params} = google_authorization!()
assert_error!(
GoogleAssentAdapter.callback(
@google_redirect_uri,
Map.put(mismatch_params, "state", "mismatched-state"),
mismatch_session
),
"Google state mismatch unexpectedly succeeded"
)
mismatch_state = state!(base_url)["google"]
assert!(mismatch_state["token_requests"] == 0, "Google state mismatch reached token endpoint")
control!(base_url, "google", "nonce_mismatch")
{nonce_session, nonce_params} = google_authorization!()
assert_error!(
GoogleAssentAdapter.callback(
@google_redirect_uri,
nonce_params,
nonce_session
),
"Google nonce mismatch unexpectedly succeeded"
)
nonce_state = state!(base_url)["google"]
assert!(nonce_state["token_requests"] == 1, "Google nonce test skipped token exchange")
assert!(nonce_state["jwks_requests"] == 1, "Google nonce test skipped signature verification")
control!(base_url, "google", "unverified_email")
{email_session, email_params} = google_authorization!()
assert!(
GoogleAssentAdapter.callback(
@google_redirect_uri,
email_params,
email_session
) == {:error, :email_not_verified},
"Google unverified email was accepted"
)
%{
success: "passed",
state_mismatch_blocked_before_token: true,
one_time_code_replay_rejected: true,
nonce_mismatch_rejected_after_signature_verification: true,
unverified_email_rejected: true,
access_token_returned_to_application: false
}
end
defp google_authorization! do
{:ok, %{url: url, session_params: session_params}} =
GoogleAssentAdapter.authorize_url(@google_redirect_uri)
response = Req.get!(url, redirect: false, retry: false)
assert!(response.status == 302, "Google OIDC mock did not redirect")
[location] = Req.Response.get_header(response, "location")
params = location |> URI.parse() |> Map.fetch!(:query) |> URI.decode_query()
{session_params, params}
end
defp oauth_drill(base_url) do
assert!(AssentAdapter.enabled?(:github), "runtime GitHub OAuth config is disabled")

View File

@ -0,0 +1,35 @@
defmodule WhoNeedHelp.GoogleAuth do
@moduledoc """
Provider boundary for Google OpenID Connect authentication.
Provider access and ID tokens are consumed inside the adapter and are never
returned to controllers or persisted by the application.
"""
@type identity :: %{
provider_uid: String.t(),
email: String.t(),
email_verified: true,
display_name: String.t()
}
@callback enabled?() :: boolean()
@callback authorize_url(String.t()) ::
{:ok, %{url: String.t(), session_params: map()}} | {:error, term()}
@callback callback(String.t(), map(), map()) :: {:ok, identity()} | {:error, term()}
def enabled?, do: adapter().enabled?()
def authorize_url(redirect_uri), do: adapter().authorize_url(redirect_uri)
def callback(redirect_uri, params, session_params),
do: adapter().callback(redirect_uri, params, session_params)
defp adapter do
Application.get_env(
:who_need_help,
:google_auth_adapter,
WhoNeedHelp.GoogleAuth.AssentAdapter
)
end
end

View File

@ -0,0 +1,91 @@
defmodule WhoNeedHelp.GoogleAuth.AssentAdapter do
@moduledoc false
@behaviour WhoNeedHelp.GoogleAuth
alias Assent.Strategy.Google
@impl true
def enabled?, do: not is_nil(provider_config())
@impl true
def authorize_url(redirect_uri) do
with {:ok, config} <- config(redirect_uri) do
config
|> Keyword.put(:nonce, random_url_token(32))
|> Keyword.put(:code_verifier, true)
|> Google.authorize_url()
end
end
@impl true
def callback(redirect_uri, params, session_params) do
with {:ok, config} <- config(redirect_uri),
{:ok, %{user: user}} <-
config
|> Keyword.put(:code_verifier, true)
|> Keyword.put(:session_params, session_params)
|> Google.callback(params) do
normalize_identity(user)
end
end
def normalize_identity(
%{
"sub" => provider_uid,
"email" => email,
"email_verified" => true
} = claims
)
when is_binary(provider_uid) and provider_uid != "" and is_binary(email) and email != "" do
normalized_email = email |> String.trim() |> String.downcase()
{:ok,
%{
provider_uid: provider_uid,
email: normalized_email,
email_verified: true,
display_name: display_name(normalized_email, Map.get(claims, "name"))
}}
end
def normalize_identity(%{"email_verified" => false}), do: {:error, :email_not_verified}
def normalize_identity(_claims), do: {:error, :invalid_provider_identity}
defp config(redirect_uri) do
case provider_config() do
nil -> {:error, :provider_disabled}
config -> {:ok, Keyword.put(config, :redirect_uri, redirect_uri)}
end
end
defp provider_config do
case Application.get_env(:who_need_help, :google_auth, []) do
config when is_list(config) and config != [] -> config
_disabled -> nil
end
end
defp display_name(email, name) when is_binary(name) do
case String.trim(name) do
"" -> fallback_display_name(email)
value -> String.slice(value, 0, 80)
end
end
defp display_name(email, _name), do: fallback_display_name(email)
defp fallback_display_name(email) do
candidate = email |> String.split("@", parts: 2) |> hd() |> String.trim()
if String.length(candidate) >= 2,
do: String.slice(candidate, 0, 80),
else: "Google user"
end
defp random_url_token(length) do
length
|> :crypto.strong_rand_bytes()
|> Base.url_encode64(padding: false)
end
end

View File

@ -123,6 +123,59 @@ defmodule WhoNeedHelpWeb.CoreComponents do
end
end
@doc """
Renders the Google authentication action with the standard multicolor G.
The icon geometry and light button treatment follow Google's published
Sign in with Google branding guidance. The form itself owns the action and
CSRF token so the button remains a normal accessible submit control.
"""
attr :label, :string, required: true
attr :disabled, :boolean, default: false
attr :rest, :global, include: ~w(id name value form)
def google_auth_button(assigns) do
~H"""
<button
type="submit"
disabled={@disabled}
class={[
"flex h-11 w-full items-center justify-center rounded-full border border-[#747775]",
"bg-white px-3 text-sm font-semibold text-[#1f1f1f] transition hover:bg-[#f8faff]",
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#1a73e8]",
@disabled && "cursor-not-allowed opacity-50"
]}
{@rest}
>
<svg
viewBox="0 0 24 24"
width="18"
height="18"
aria-hidden="true"
class="mr-2.5 shrink-0"
>
<path
fill="#4285F4"
d="M21.6 12.23c0-.71-.06-1.4-.18-2.05H12v3.87h5.38a4.6 4.6 0 0 1-2 3.02v2.51h3.24c1.9-1.75 2.98-4.33 2.98-7.35Z"
/>
<path
fill="#34A853"
d="M12 22c2.7 0 4.97-.9 6.62-2.42l-3.24-2.51c-.9.6-2.05.96-3.38.96-2.6 0-4.81-1.76-5.6-4.12H3.05v2.59A10 10 0 0 0 12 22Z"
/>
<path
fill="#FBBC05"
d="M6.4 13.91A6.01 6.01 0 0 1 6.08 12c0-.66.11-1.3.32-1.91V7.5H3.05A10 10 0 0 0 2 12c0 1.61.39 3.14 1.05 4.5l3.35-2.59Z"
/>
<path
fill="#EA4335"
d="M12 5.97c1.47 0 2.79.5 3.82 1.5l2.87-2.87A9.62 9.62 0 0 0 12 2a10 10 0 0 0-8.95 5.5l3.35 2.59c.79-2.36 3-4.12 5.6-4.12Z"
/>
</svg>
<span>{@label}</span>
</button>
"""
end
@doc """
Renders an input with label and error messages.

View File

@ -0,0 +1,278 @@
defmodule WhoNeedHelpWeb.GoogleAuthController do
use WhoNeedHelpWeb, :controller
require Logger
alias WhoNeedHelp.{Accounts, GoogleAuth, Repo, Trust}
alias WhoNeedHelp.Trust.RateLimiter
alias WhoNeedHelpWeb.UserAuth
import WhoNeedHelpWeb.UserAuth, only: [require_sudo_mode: 2]
@session_key "google_auth_flow"
@supported_locales ~w(en uk ru)
plug :put_no_store
plug :require_sudo_mode when action in [:start_link, :disconnect]
def start_login(conn, _params) do
start_flow(conn, "login", %{}, ~p"/users/log-in")
end
def start_registration(conn, %{"google_registration" => params}) when is_map(params) do
locale = normalize_locale(params["locale"])
terms_accepted = params["terms_accepted"] in [true, "true", "on", "1"]
if terms_accepted do
start_flow(
conn,
"register",
%{"locale" => locale, "terms_accepted" => true},
~p"/users/register"
)
else
conn
|> put_flash(
:error,
gettext("Confirm that you are 18 or older and accept the safety rules first.")
)
|> redirect(to: ~p"/users/register")
end
end
def start_registration(conn, _params) do
conn
|> put_flash(:error, gettext("The Google registration form is invalid."))
|> redirect(to: ~p"/users/register")
end
def start_link(conn, _params) do
user = conn.assigns.current_scope.user
start_flow(conn, "link", %{"user_id" => user.id}, ~p"/users/settings")
end
def disconnect(conn, _params) do
current_user = conn.assigns.current_scope.user
case unlink_google_identity_and_audit(current_user) do
{:ok, _identity} ->
conn
|> put_flash(:info, gettext("Google sign-in disconnected."))
|> redirect(to: ~p"/users/settings")
{:error, :not_found} ->
conn
|> put_flash(:error, gettext("No Google account is connected."))
|> redirect(to: ~p"/users/settings")
{:error, _reason} ->
google_account_unavailable(conn, ~p"/users/settings")
end
end
def callback(conn, params) do
flow = get_session(conn, @session_key)
conn = delete_session(conn, @session_key)
with %{"flow" => flow_name, "session_params" => session_params} <- flow,
true <- flow_name in ["login", "register", "link"],
{:ok, identity_attrs} <-
GoogleAuth.callback(callback_url(conn), params, session_params) do
finish_flow(conn, flow_name, flow, identity_attrs)
else
nil ->
callback_error(
conn,
flow,
gettext("Google sign-in session expired. Please start again.")
)
false ->
callback_error(conn, flow, gettext("Google sign-in session is invalid."))
{:error, :email_not_verified} ->
callback_error(
conn,
flow,
gettext("Google did not provide a verified email address.")
)
{:error, error} ->
Logger.warning("Google OAuth callback failed (#{error_name(error)})")
callback_error(conn, flow, gettext("Google sign-in failed. Please try again."))
end
end
defp start_flow(conn, flow, extra, failure_path) do
if GoogleAuth.enabled?() do
case GoogleAuth.authorize_url(callback_url(conn)) do
{:ok, %{url: url, session_params: session_params}} ->
session =
extra
|> Map.put("flow", flow)
|> Map.put("session_params", session_params)
conn
|> put_session(@session_key, session)
|> redirect(external: url)
{:error, error} ->
Logger.warning("Unable to start Google OAuth (#{error_name(error)})")
conn
|> put_flash(:error, gettext("Google sign-in could not be started."))
|> redirect(to: failure_path)
end
else
conn
|> put_flash(:error, gettext("Google sign-in is not configured yet."))
|> redirect(to: failure_path)
end
end
defp finish_flow(conn, "login", _flow, identity_attrs) do
case Accounts.login_user_by_google(identity_attrs) do
{:ok, user} ->
conn
|> put_flash(:info, gettext("Welcome back!"))
|> UserAuth.log_in_user(user)
{:error, :not_linked} ->
conn
|> put_flash(
:error,
gettext(
"This Google account is not connected yet. Create an account or sign in by email and connect Google in account settings."
)
)
|> redirect(to: ~p"/users/log-in")
{:error, _reason} ->
google_account_unavailable(conn, ~p"/users/log-in")
end
end
defp finish_flow(conn, "register", flow, identity_attrs) do
with {:ok, _limit} <- RateLimiter.check(:registration_email, identity_attrs.email),
{:ok, {user, _identity}} <-
Accounts.register_user_by_google(identity_attrs, %{
"locale" => flow["locale"],
"terms_accepted" => flow["terms_accepted"]
}) do
conn
|> put_flash(:info, gettext("Your account was created with Google."))
|> UserAuth.log_in_user(user)
else
{:error, :email_already_registered} ->
conn
|> put_flash(
:error,
gettext(
"An account with this email already exists. Sign in by email or password, then connect Google in account settings."
)
)
|> redirect(to: ~p"/users/log-in")
{:error, :rate_limited} ->
conn
|> put_flash(
:error,
gettext("Too many registration attempts in the configured time window.")
)
|> redirect(to: ~p"/users/register")
{:error, _reason} ->
google_account_unavailable(conn, ~p"/users/register")
end
end
defp finish_flow(conn, "link", flow, identity_attrs) do
current_user = get_in(conn.assigns, [:current_scope, Access.key(:user)])
if current_user && flow["user_id"] == current_user.id &&
Accounts.sudo_mode?(current_user, -10) do
case link_google_identity_and_audit(current_user, identity_attrs) do
{:ok, _identity} ->
conn
|> put_flash(:info, gettext("Google sign-in connected."))
|> redirect(to: ~p"/users/settings")
{:error, :already_linked} ->
conn
|> put_flash(:error, gettext("That Google account belongs to another local account."))
|> redirect(to: ~p"/users/settings")
{:error, :provider_already_linked} ->
conn
|> put_flash(:error, gettext("A different Google account is already connected."))
|> redirect(to: ~p"/users/settings")
{:error, _reason} ->
google_account_unavailable(conn, ~p"/users/settings")
end
else
conn
|> put_flash(:error, gettext("Google account connection session is invalid."))
|> redirect(to: ~p"/users/log-in")
end
end
defp link_google_identity_and_audit(current_user, identity_attrs) do
Repo.transact(fn ->
with {:ok, identity} <- Accounts.link_google_identity(current_user, identity_attrs),
{:ok, _audit} <-
Trust.audit(
current_user.id,
"auth_identity.connected",
"auth_identity",
identity.id,
%{"provider" => "google"}
) do
{:ok, identity}
end
end)
end
defp unlink_google_identity_and_audit(current_user) do
Repo.transact(fn ->
with {:ok, identity} <- Accounts.unlink_google_identity(current_user),
{:ok, _audit} <-
Trust.audit(
current_user.id,
"auth_identity.disconnected",
"auth_identity",
identity.id,
%{"provider" => "google"}
) do
{:ok, identity}
end
end)
end
defp callback_error(conn, flow, message) do
conn
|> put_flash(:error, message)
|> redirect(to: failure_path(flow))
end
defp google_account_unavailable(conn, path) do
conn
|> put_flash(:error, gettext("This Google account cannot be used right now."))
|> redirect(to: path)
end
defp failure_path(%{"flow" => "register"}), do: ~p"/users/register"
defp failure_path(%{"flow" => "link"}), do: ~p"/users/settings"
defp failure_path(_flow), do: ~p"/users/log-in"
defp callback_url(conn), do: url(conn, ~p"/auth/google/callback")
defp normalize_locale(locale) when locale in @supported_locales, do: locale
defp normalize_locale(_locale), do: "en"
defp error_name(error) when is_atom(error), do: Atom.to_string(error)
defp error_name(%{__struct__: module}), do: inspect(module)
defp error_name(_error), do: "unknown_error"
defp put_no_store(conn, _opts), do: put_resp_header(conn, "cache-control", "no-store")
end

View File

@ -3,7 +3,7 @@ defmodule WhoNeedHelpWeb.UserRegistrationController do
require Logger
alias WhoNeedHelp.Accounts
alias WhoNeedHelp.{Accounts, GoogleAuth}
alias WhoNeedHelp.Accounts.User
alias WhoNeedHelp.Trust.RateLimiter
@ -11,7 +11,7 @@ defmodule WhoNeedHelpWeb.UserRegistrationController do
def new(conn, _params) do
changeset = Accounts.change_user_registration(%User{}, %{}, validate_unique: false)
render(conn, :new, changeset: changeset)
render_registration(conn, changeset)
end
def create(conn, %{"user" => user_params}) when is_map(user_params) do
@ -37,7 +37,7 @@ defmodule WhoNeedHelpWeb.UserRegistrationController do
registration_response(conn)
else
render(conn, :new, changeset: changeset)
render_registration(conn, changeset)
end
end
else
@ -48,9 +48,7 @@ defmodule WhoNeedHelpWeb.UserRegistrationController do
:error,
gettext("Too many registration attempts in the configured time window.")
)
|> render(:new,
changeset: Accounts.change_user_registration(%User{}, user_params)
)
|> render_registration(Accounts.change_user_registration(%User{}, user_params))
end
end
@ -58,8 +56,8 @@ defmodule WhoNeedHelpWeb.UserRegistrationController do
conn
|> put_status(:bad_request)
|> put_flash(:error, gettext("The registration form is invalid."))
|> render(:new,
changeset: Accounts.change_user_registration(%User{}, %{}, validate_unique: false)
|> render_registration(
Accounts.change_user_registration(%User{}, %{}, validate_unique: false)
)
end
@ -124,5 +122,12 @@ defmodule WhoNeedHelpWeb.UserRegistrationController do
end)
end
defp render_registration(conn, changeset) do
render(conn, :new,
changeset: changeset,
google_auth_enabled: GoogleAuth.enabled?()
)
end
defp put_no_store(conn, _opts), do: put_resp_header(conn, "cache-control", "no-store")
end

View File

@ -1,5 +1,5 @@
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="mx-auto max-w-sm">
<div class="mx-auto max-w-sm space-y-5">
<div class="text-center">
<.header>
{gettext("Register for an account")}
@ -13,7 +13,49 @@
</.header>
</div>
<.form :let={f} for={@changeset} action={~p"/users/register"}>
<p class="rounded-2xl bg-base-200 p-4 text-sm text-base-content/70">
{gettext(
"A password is not required. We will email a one-time confirmation link. You can add a password later in account settings."
)}
</p>
<.form
for={%{}}
as={:google_registration}
action={~p"/auth/google/register"}
id="google_registration_form"
class="space-y-3"
>
<input
type="hidden"
name="google_registration[locale]"
value={Gettext.get_locale(WhoNeedHelpWeb.Gettext)}
/>
<.input
type="checkbox"
id="google_registration_terms"
name="google_registration[terms_accepted]"
value="false"
label={gettext("I am 18 or older and accept the safety rules")}
required
/>
<.google_auth_button
label={gettext("Sign up with Google")}
disabled={!@google_auth_enabled}
/>
<p :if={!@google_auth_enabled} class="text-center text-xs text-base-content/55">
{gettext("Google sign-in will be available after the operator configures it.")}
</p>
</.form>
<div class="divider">{gettext("or sign up with email")}</div>
<.form
:let={f}
for={@changeset}
action={~p"/users/register"}
id="email_registration_form"
>
<input
type="hidden"
name={f[:locale].name}
@ -55,8 +97,8 @@
)}
</p>
<.button phx-disable-with={gettext("Creating account...")} class="btn btn-primary w-full">
{gettext("Create an account")}
<.button phx-disable-with={gettext("Sending link...")} class="btn btn-primary w-full">
{gettext("Create account and email me a link")}
</.button>
</.form>
</div>

View File

@ -3,7 +3,7 @@ defmodule WhoNeedHelpWeb.UserSessionController do
require Logger
alias WhoNeedHelp.Accounts
alias WhoNeedHelp.{Accounts, GoogleAuth}
alias WhoNeedHelp.Trust.RateLimiter
alias WhoNeedHelpWeb.UserAuth
@ -126,11 +126,12 @@ defmodule WhoNeedHelpWeb.UserSessionController do
end
defp assign_magic_link_form(conn, _opts) do
assign(
conn,
conn
|> assign(
:magic_link_form,
Phoenix.Component.to_form(%{"token" => ""}, as: "user")
)
|> assign(:google_auth_enabled, GoogleAuth.enabled?())
end
defp put_no_store(conn, _opts), do: put_resp_header(conn, "cache-control", "no-store")

View File

@ -63,6 +63,24 @@
</.form>
<div id="standard-login-options">
<.form
:if={!@current_scope}
for={%{}}
as={:google_login}
action={~p"/auth/google/login"}
id="google_login_form"
>
<.google_auth_button
label={gettext("Sign in with Google")}
disabled={!@google_auth_enabled}
/>
<p :if={!@google_auth_enabled} class="mt-2 text-center text-xs text-base-content/55">
{gettext("Google sign-in will be available after the operator configures it.")}
</p>
</.form>
<div :if={!@current_scope} class="divider">{gettext("or use email")}</div>
<.form :let={f} for={@form} as={:user} id="login_form_magic" action={~p"/users/log-in"}>
<.input
readonly={!!@current_scope}
@ -74,14 +92,29 @@
required
phx-mounted={JS.focus()}
/>
<p class="mb-3 text-xs text-base-content/60">
{gettext("We will send a one-time sign-in link. No password is required.")}
</p>
<.button class="btn btn-primary w-full">
{gettext("Log in with email")} <span aria-hidden="true">→</span>
{gettext("Email me a sign-in link")} <span aria-hidden="true">→</span>
</.button>
</.form>
<div class="divider">{gettext("or")}</div>
<.form :let={f} for={@form} as={:user} id="login_form_password" action={~p"/users/log-in"}>
<details class="collapse collapse-arrow mt-4 rounded-2xl border border-base-300">
<summary class="collapse-title font-semibold">
{gettext("Use a password instead")}
</summary>
<div class="collapse-content">
<p class="mb-3 text-xs text-base-content/60">
{gettext("This works only if you previously added a password in account settings.")}
</p>
<.form
:let={f}
for={@form}
as={:user}
id="login_form_password"
action={~p"/users/log-in"}
>
<.input
readonly={!!@current_scope}
field={f[:email]}
@ -97,6 +130,7 @@
label={gettext("Password")}
autocomplete="current-password"
spellcheck="false"
required
/>
<.button class="btn btn-primary w-full" name={@form[:remember_me].name} value="true">
{gettext("Log in and stay logged in")} <span aria-hidden="true">→</span>
@ -106,5 +140,7 @@
</.button>
</.form>
</div>
</details>
</div>
</div>
</Layouts.app>

View File

@ -3,7 +3,7 @@ defmodule WhoNeedHelpWeb.UserSettingsController do
require Logger
alias WhoNeedHelp.Accounts
alias WhoNeedHelp.{Accounts, GoogleAuth}
alias WhoNeedHelp.Trust.RateLimiter
alias WhoNeedHelpWeb.UserAuth
@ -103,6 +103,8 @@ defmodule WhoNeedHelpWeb.UserSettingsController do
conn
|> assign(:email_changeset, Accounts.change_user_email(user))
|> assign(:password_changeset, Accounts.change_user_password(user))
|> assign(:google_auth_enabled, GoogleAuth.enabled?())
|> assign(:google_auth_connected, Accounts.google_auth_connected?(user))
end
defp deliver_email_change_instructions(conn, user, changeset) do

View File

@ -25,6 +25,55 @@
<div class="divider" />
<section class="rounded-3xl border border-base-300 p-5">
<h2 class="text-lg font-bold">{gettext("Google sign-in")}</h2>
<p class="mt-1 text-sm text-base-content/60">
<%= if @google_auth_connected do %>
{gettext("A Google account is connected. You can use it to sign in.")}
<% else %>
{gettext(
"Connect Google only after signing in here. We never merge accounts automatically by matching email addresses."
)}
<% end %>
</p>
<.form
:if={!@google_auth_connected}
for={%{}}
as={:google_link}
action={~p"/auth/google/link"}
id="google_link_form"
class="mt-4"
>
<.google_auth_button
label={gettext("Connect Google account")}
disabled={!@google_auth_enabled}
/>
<p :if={!@google_auth_enabled} class="mt-2 text-center text-xs text-base-content/55">
{gettext("Google sign-in will be available after the operator configures it.")}
</p>
</.form>
<.form
:if={@google_auth_connected}
for={%{}}
action={~p"/auth/google/link"}
method="delete"
id="google_unlink_form"
class="mt-4"
>
<.button
variant="primary"
class="btn-soft"
data-confirm={gettext("Disconnect Google sign-in from this account?")}
>
{gettext("Disconnect Google")}
</.button>
</.form>
</section>
<div class="divider" />
<.form :let={f} for={@password_changeset} action={~p"/users/settings"} id="update_password">
<input type="hidden" name="action" value="update_password" />
<input

View File

@ -103,6 +103,8 @@ defmodule WhoNeedHelpWeb.Router do
get "/users/register", UserRegistrationController, :new
post "/users/register", UserRegistrationController, :create
post "/auth/google/login", GoogleAuthController, :start_login
post "/auth/google/register", GoogleAuthController, :start_registration
end
scope "/", WhoNeedHelpWeb do
@ -112,6 +114,8 @@ defmodule WhoNeedHelpWeb.Router do
put "/users/settings", UserSettingsController, :update
get "/users/settings/confirm-email", UserSettingsController, :confirm_email_page
post "/users/settings/confirm-email", UserSettingsController, :confirm_email
post "/auth/google/link", GoogleAuthController, :start_link
delete "/auth/google/link", GoogleAuthController, :disconnect
get "/auth/social/:provider", SocialOAuthController, :request
get "/auth/social/:provider/callback", SocialOAuthController, :callback
end
@ -143,6 +147,7 @@ defmodule WhoNeedHelpWeb.Router do
get "/users/log-in", UserSessionController, :new
post "/users/log-in", UserSessionController, :create
get "/auth/google/callback", GoogleAuthController, :callback
delete "/users/log-out", UserSessionController, :delete
end
end

View File

@ -33,6 +33,13 @@ class BoundaryState:
self.oauth_code_counter = 0
self.oauth_codes: dict[str, dict[str, Any]] = {}
self.oauth_tokens: set[str] = set()
self.google_mode = "success"
self.google_discovery_requests = 0
self.google_authorize_requests = 0
self.google_token_requests = 0
self.google_jwks_requests = 0
self.google_code_counter = 0
self.google_codes: dict[str, dict[str, Any]] = {}
self.push_mode = "success"
self.push_attempts = 0
self.push_receipts: dict[str, str] = {}
@ -56,6 +63,13 @@ class BoundaryState:
self.oauth_user_requests = 0
self.oauth_codes = {}
self.oauth_tokens = set()
elif component == "google":
self.google_mode = mode
self.google_discovery_requests = 0
self.google_authorize_requests = 0
self.google_token_requests = 0
self.google_jwks_requests = 0
self.google_codes = {}
elif component == "push":
self.push_mode = mode
self.push_attempts = 0
@ -87,6 +101,17 @@ class BoundaryState:
1 for code in self.oauth_codes.values() if code["used"]
),
},
"google": {
"mode": self.google_mode,
"discovery_requests": self.google_discovery_requests,
"authorize_requests": self.google_authorize_requests,
"token_requests": self.google_token_requests,
"jwks_requests": self.google_jwks_requests,
"issued_codes": len(self.google_codes),
"consumed_codes": sum(
1 for code in self.google_codes.values() if code["used"]
),
},
"push": {
"mode": self.push_mode,
"attempts": self.push_attempts,
@ -107,11 +132,74 @@ STATE = BoundaryState()
OAUTH_CLIENT_ID = os.environ["MOCK_OAUTH_CLIENT_ID"]
OAUTH_CLIENT_SECRET = os.environ["MOCK_OAUTH_CLIENT_SECRET"]
PUSH_BEARER_TOKEN = os.environ["MOCK_PUSH_BEARER_TOKEN"]
GOOGLE_ISSUER = "http://external-mock:8080"
GOOGLE_KEY_ID = "local-google-rs256"
GOOGLE_RSA_MODULUS = int(
"a42d39e6e3244bdb67afce39612a6c54c27c88d3b730eaabdb70615c59d005e7"
"cdb22585d196bd337b4c9d80feb5bdc04046ccdba2c523bfbb567cb8165bfa33"
"f9df6f88828a7acbe6586a003d105b709bdfdd6bbfee36167582f9daa410d792"
"bcc1c7d3f9bfc8964d8c58250a35540dc8424cf661ec76f76326162d518be5fa"
"f345922951f0f1f805c34a1a83c2c4f3805677e0faf37f3850fcbbaf4f6e90db"
"b0f90f5b55548f56a43e69a2f805b257dec650d8ad7417188e379df5050214"
"fc52f05886b8b5407775793afd6145fae83b1e9728c272e71b80d87ed5d0bec0"
"63c3703996c8dae1e672803855d59f561fdbb5ee64464d7d09146f530fc03259c5",
16,
)
GOOGLE_RSA_PRIVATE_EXPONENT = int(
"14b1d2bba4e41d5fc1b92a70972be6cde45a18513fa53ddf7de0b39515892045"
"70eb44c9927ac2ccab7d23d96fc1eef23de7eec8bcc2c6d7d3407aa625c3604d"
"8ef0b83967e316c97ef6a41df5948b422d93d17054982d5f355ed629d6467d35"
"f4ef2446371412afc784aa53b8eeb1f2aecc94b0f5f4fda5ff6c7c9d27cb4fa8"
"d78f0902df05a4c068e180b1a345ee46607dc4cdf920ee31405a7931dcb61b40"
"65e1e03963df352553a485a1e31cc072adc46eda1a4dcce71bbe3947c1bc5d7f"
"a8134d2e58d93792b0776958abf3bc7376846c1cac4c0b634371c80476d847b4"
"11cd607add231e66341d3def88005d84707ef7715fe288bd881ea5d8430eb001",
16,
)
GOOGLE_RSA_EXPONENT = 65537
def json_bytes(value: Any) -> bytes:
return json.dumps(value, separators=(",", ":"), sort_keys=True).encode()
def base64url(value: bytes) -> str:
return base64.urlsafe_b64encode(value).rstrip(b"=").decode()
def unsigned_bytes(value: int) -> bytes:
return value.to_bytes((value.bit_length() + 7) // 8, "big")
def google_id_token(code: dict[str, Any], mode: str) -> str:
now = int(time.time())
header = {"alg": "RS256", "kid": GOOGLE_KEY_ID, "typ": "JWT"}
claims = {
"iss": GOOGLE_ISSUER,
"sub": "google-local-subject",
"aud": OAUTH_CLIENT_ID,
"exp": now + 300,
"iat": now,
"nonce": (
"invalid-nonce" if mode == "nonce_mismatch" else code["nonce"]
),
"email": "google-helper@example.invalid",
"email_verified": mode != "unverified_email",
"name": "Google Local Helper",
}
signing_input = (
f"{base64url(json_bytes(header))}.{base64url(json_bytes(claims))}"
).encode()
digest_info = bytes.fromhex("3031300d060960864801650304020105000420")
digest_info += hashlib.sha256(signing_input).digest()
padding = b"\xff" * (256 - len(digest_info) - 3)
encoded_message = b"\x00\x01" + padding + b"\x00" + digest_info
signature = pow(
int.from_bytes(encoded_message, "big"),
GOOGLE_RSA_PRIVATE_EXPONENT,
GOOGLE_RSA_MODULUS,
).to_bytes(256, "big")
return f"{signing_input.decode()}.{base64url(signature)}"
class BoundaryHTTPServer(ThreadingHTTPServer):
daemon_threads = True
@ -151,6 +239,12 @@ class BoundaryHTTPHandler(BaseHTTPRequestHandler):
self.oauth_authorize(parsed)
elif parsed.path == "/oauth/user":
self.oauth_user()
elif parsed.path == "/.well-known/openid-configuration":
self.google_discovery()
elif parsed.path == "/google/authorize":
self.google_authorize(parsed)
elif parsed.path == "/google/jwks":
self.google_jwks()
else:
self.send_json(404, {"error": "not_found"})
@ -167,6 +261,8 @@ class BoundaryHTTPHandler(BaseHTTPRequestHandler):
self.control(body)
elif parsed.path == "/oauth/token":
self.oauth_token(body)
elif parsed.path == "/google/token":
self.google_token(body)
elif parsed.path == "/push":
self.push(body)
else:
@ -314,6 +410,139 @@ class BoundaryHTTPHandler(BaseHTTPRequestHandler):
},
)
def google_discovery(self) -> None:
with STATE.lock:
STATE.google_discovery_requests += 1
self.send_json(
200,
{
"issuer": GOOGLE_ISSUER,
"authorization_endpoint": f"{GOOGLE_ISSUER}/google/authorize",
"token_endpoint": f"{GOOGLE_ISSUER}/google/token",
"jwks_uri": f"{GOOGLE_ISSUER}/google/jwks",
"token_endpoint_auth_methods_supported": ["client_secret_post"],
},
)
def google_authorize(self, parsed: urllib.parse.SplitResult) -> None:
params = urllib.parse.parse_qs(parsed.query)
required = {
"client_id",
"redirect_uri",
"state",
"nonce",
"scope",
"code_challenge",
"code_challenge_method",
}
if (
not required.issubset(params)
or params["client_id"][0] != OAUTH_CLIENT_ID
or params["code_challenge_method"][0] != "S256"
or set(params["scope"][0].split()) != {"openid", "email", "profile"}
):
self.send_json(400, {"error": "invalid_google_authorization_request"})
return
redirect_uri = params["redirect_uri"][0]
state = params["state"][0]
with STATE.lock:
STATE.google_authorize_requests += 1
STATE.google_code_counter += 1
code = f"google-boundary-code-{STATE.google_code_counter}"
STATE.google_codes[code] = {
"challenge": params["code_challenge"][0],
"redirect_uri": redirect_uri,
"nonce": params["nonce"][0],
"used": False,
}
location = (
f"{redirect_uri}?{urllib.parse.urlencode({'code': code, 'state': state})}"
)
self.send_response(302)
self.send_header("location", location)
self.send_header("content-length", "0")
self.end_headers()
def google_token(self, body: bytes) -> None:
params = {
key: values[0]
for key, values in urllib.parse.parse_qs(body.decode()).items()
}
with STATE.lock:
STATE.google_token_requests += 1
mode = STATE.google_mode
code = STATE.google_codes.get(params.get("code", ""))
verifier = params.get("code_verifier", "")
challenge = base64url(hashlib.sha256(verifier.encode()).digest())
checks = {
"client_id": params.get("client_id") == OAUTH_CLIENT_ID,
"client_secret": params.get("client_secret") == OAUTH_CLIENT_SECRET,
"grant_type": params.get("grant_type") == "authorization_code",
"known_code": code is not None,
"unused_code": code is not None and not code["used"],
"redirect_uri": (
code is not None and code["redirect_uri"] == params.get("redirect_uri")
),
"pkce": code is not None and code["challenge"] == challenge,
}
valid = all(checks.values())
if not valid:
failed_checks = sorted(name for name, passed in checks.items() if not passed)
if not checks["pkce"] and code is not None:
failed_checks.append(
"pkce_lengths_"
f"{len(verifier)}_{len(code['challenge'])}_{len(challenge)}"
)
self.send_json(
400,
{
"error": "invalid_grant",
"error_description": ",".join(failed_checks),
},
)
return
with STATE.lock:
code["used"] = True
self.send_json(
200,
{
"access_token": "google-boundary-access-token",
"id_token": google_id_token(code, mode),
"scope": "openid email profile",
"token_type": "Bearer",
},
)
def google_jwks(self) -> None:
with STATE.lock:
STATE.google_jwks_requests += 1
self.send_json(
200,
{
"keys": [
{
"alg": "RS256",
"e": base64url(unsigned_bytes(GOOGLE_RSA_EXPONENT)),
"kid": GOOGLE_KEY_ID,
"kty": "RSA",
"n": base64url(unsigned_bytes(GOOGLE_RSA_MODULUS)),
"use": "sig",
}
]
},
)
def push(self, body: bytes) -> None:
try:
notification = json.loads(body)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,18 @@
defmodule WhoNeedHelp.Repo.Migrations.CreateAuthIdentities do
use Ecto.Migration
def change do
create table(:auth_identities, primary_key: false) do
add :id, :binary_id, primary_key: true
add :provider, :string, null: false
add :provider_uid, :string, null: false
add :email, :string, null: false
add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
timestamps(type: :utc_datetime)
end
create unique_index(:auth_identities, [:provider, :provider_uid])
create unique_index(:auth_identities, [:user_id, :provider])
end
end

View File

@ -154,6 +154,8 @@ PHX_URL_PORT=80
MAP_TILE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png
GITHUB_OAUTH_CLIENT_ID=
GITHUB_OAUTH_CLIENT_SECRET=
GOOGLE_OAUTH_CLIENT_ID=
GOOGLE_OAUTH_CLIENT_SECRET=
PUSH_HTTP_ENDPOINT=
PUSH_HTTP_BEARER_TOKEN=
POSTGRES_DB=$postgres_db

View File

@ -85,6 +85,8 @@ CODEX_SESSION_ID=local-e2e
RATE_LIMIT_POLICIES_JSON={}
GITHUB_OAUTH_CLIENT_ID=
GITHUB_OAUTH_CLIENT_SECRET=
GOOGLE_OAUTH_CLIENT_ID=
GOOGLE_OAUTH_CLIENT_SECRET=
E2E_BASE_URL=https://proxy
E2E_MAILPIT_URL=http://mailpit:8025
E2E_OUTPUT_DIR=$ROOT/output/e2e/generated-per-run

View File

@ -146,6 +146,12 @@ jq -e '
.oauth.fresh_flow_retry_after_temporary_failure == "passed" and
.oauth.timeout_failed_closed == true and
.oauth.access_token_returned_to_application == false and
.google_oidc.success == "passed" and
.google_oidc.state_mismatch_blocked_before_token == true and
.google_oidc.one_time_code_replay_rejected == true and
.google_oidc.nonce_mismatch_rejected_after_signature_verification == true and
.google_oidc.unverified_email_rejected == true and
.google_oidc.access_token_returned_to_application == false and
.smtp.success == "passed" and
.smtp.permanent_rejection_not_retried == true and
.smtp.temporary_greeting_retried_once == true and

View File

@ -103,6 +103,16 @@ PRODUCTION_EMAIL_FROM_ADDRESS=contact@help.test \
./scripts/init-production-env.sh help.test "$production_env" >/dev/null
test "$(stat -c '%a' "$production_env")" = 600
./scripts/validate-production-env.sh "$production_env" help.test >/dev/null
partial_google_env="$scan_dir/.env.production.partial-google"
cp "$production_env" "$partial_google_env"
chmod 600 "$partial_google_env"
sed -i 's/^GOOGLE_OAUTH_CLIENT_ID=.*/GOOGLE_OAUTH_CLIENT_ID=quality-client/' \
"$partial_google_env"
if ./scripts/validate-production-env.sh \
"$partial_google_env" help.test >/dev/null 2>&1; then
echo "Production environment validator accepted partial Google OAuth credentials." >&2
exit 1
fi
if ./scripts/init-production-env.sh help.test "$production_env" >/dev/null 2>&1; then
echo "Production environment initializer overwrote an existing file." >&2
exit 1

View File

@ -4,6 +4,15 @@ set -eu
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
ENV_FILE="$ROOT/.env"
TEST_IMAGE="${TEST_IMAGE:-who-need-help:test}"
MODE=${1:-all}
case "$MODE" in
all | --database-only) ;;
*)
echo "Usage: $0 [--database-only]" >&2
exit 1
;;
esac
if [ ! -f "$ENV_FILE" ]; then
echo "Missing $ENV_FILE. Copy .env.example to .env first." >&2
@ -37,9 +46,6 @@ generate_phoenix_secret() {
docker run --rm "$TEST_IMAGE" mix phx.gen.secret
}
new_secret_key_base=$(generate_phoenix_secret)
new_handover_secret=$(generate_phoenix_secret)
new_release_cookie=$(generate_phoenix_secret)
new_postgres_password=$(openssl rand -hex 32)
new_database_url="ecto://${POSTGRES_USER}:${new_postgres_password}@db/${POSTGRES_DB}"
@ -47,11 +53,23 @@ tmp_env=$(mktemp "${ENV_FILE}.rotate.XXXXXX")
trap 'rm -f "$tmp_env"' EXIT HUP INT TERM
chmod 600 "$tmp_env"
NEW_SECRET_KEY_BASE=$new_secret_key_base \
NEW_HANDOVER_SECRET=$new_handover_secret \
NEW_RELEASE_COOKIE=$new_release_cookie \
NEW_POSTGRES_PASSWORD=$new_postgres_password \
NEW_DATABASE_URL=$new_database_url \
if [ "$MODE" = "--database-only" ]; then
NEW_POSTGRES_PASSWORD=$new_postgres_password \
NEW_DATABASE_URL=$new_database_url \
perl -pe '
s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$ENV{NEW_POSTGRES_PASSWORD}/;
s/^DATABASE_URL=.*/DATABASE_URL=$ENV{NEW_DATABASE_URL}/;
' "$ENV_FILE" >"$tmp_env"
else
new_secret_key_base=$(generate_phoenix_secret)
new_handover_secret=$(generate_phoenix_secret)
new_release_cookie=$(generate_phoenix_secret)
NEW_SECRET_KEY_BASE=$new_secret_key_base \
NEW_HANDOVER_SECRET=$new_handover_secret \
NEW_RELEASE_COOKIE=$new_release_cookie \
NEW_POSTGRES_PASSWORD=$new_postgres_password \
NEW_DATABASE_URL=$new_database_url \
perl -pe '
s/^SECRET_KEY_BASE=.*/SECRET_KEY_BASE=$ENV{NEW_SECRET_KEY_BASE}/;
s/^HANDOVER_SECRET=.*/HANDOVER_SECRET=$ENV{NEW_HANDOVER_SECRET}/;
@ -59,6 +77,7 @@ NEW_DATABASE_URL=$new_database_url \
s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$ENV{NEW_POSTGRES_PASSWORD}/;
s/^DATABASE_URL=.*/DATABASE_URL=$ENV{NEW_DATABASE_URL}/;
' "$ENV_FILE" >"$tmp_env"
fi
printf "ALTER ROLE \"%s\" WITH PASSWORD '%s';\n" \
"$POSTGRES_USER" "$new_postgres_password" |
@ -70,5 +89,9 @@ mv "$tmp_env" "$ENV_FILE"
chmod 600 "$ENV_FILE"
trap - EXIT HUP INT TERM
echo "Local application and PostgreSQL secrets rotated without printing their values."
if [ "$MODE" = "--database-only" ]; then
echo "Local PostgreSQL password rotated without printing its value."
else
echo "Local application and PostgreSQL secrets rotated without printing their values."
fi
echo "Run docker compose up -d --wait to apply the new application environment."

View File

@ -41,6 +41,12 @@ read_value() {
' "$env_file"
}
optional_value() {
local key=$1
read_value "$key" 2>/dev/null || true
}
require_value() {
local key=$1
local value
@ -80,10 +86,14 @@ release_cookie=$(require_value RELEASE_COOKIE)
metrics_token=$(require_value METRICS_TOKEN)
smtp_relay=$(require_value SMTP_RELAY)
smtp_port=$(require_value SMTP_PORT)
smtp_username=$(optional_value SMTP_USERNAME)
smtp_password=$(optional_value SMTP_PASSWORD)
smtp_auth=$(require_value SMTP_AUTH)
smtp_tls=$(require_value SMTP_TLS)
smtp_ssl=$(require_value SMTP_SSL)
email_from_address=$(require_value EMAIL_FROM_ADDRESS)
google_oauth_client_id=$(optional_value GOOGLE_OAUTH_CLIENT_ID)
google_oauth_client_secret=$(optional_value GOOGLE_OAUTH_CLIENT_SECRET)
[[ "$phx_host" == "$expected_domain" ]] || {
echo "PHX_HOST does not match EXPECTED_DOMAIN." >&2
@ -137,11 +147,35 @@ done
echo "SMTP_SSL has an unsupported value." >&2
exit 1
}
if [[ -n "$smtp_username" || -n "$smtp_password" ]]; then
[[ -n "$smtp_username" && -n "$smtp_password" ]] || {
echo "SMTP username and password must either both be set or both be empty." >&2
exit 1
}
fi
if [[ "$smtp_auth" == always && (-z "$smtp_username" || -z "$smtp_password") ]]; then
echo "SMTP username and password are required when SMTP_AUTH is always." >&2
exit 1
fi
if [[ "$smtp_ssl" =~ ^(true|1)$ && "$smtp_tls" != never ]]; then
echo "SMTP_TLS must be never when SMTP_SSL enables an implicit TLS connection." >&2
exit 1
fi
[[ "$email_from_address" == *@* ]] || {
echo "EMAIL_FROM_ADDRESS is not an email address." >&2
exit 1
}
if [[ -n "$google_oauth_client_id" || -n "$google_oauth_client_secret" ]]; then
[[ -n "$google_oauth_client_id" && -n "$google_oauth_client_secret" ]] || {
echo "Google OAuth client ID and secret must either both be set or both be empty." >&2
exit 1
}
reject_marker GOOGLE_OAUTH_CLIENT_ID "$google_oauth_client_id"
reject_marker GOOGLE_OAUTH_CLIENT_SECRET "$google_oauth_client_secret"
fi
secrets=(
"$postgres_password"
"$secret_key_base"

View File

@ -0,0 +1,46 @@
defmodule WhoNeedHelp.GoogleAuthFake do
@behaviour WhoNeedHelp.GoogleAuth
@impl true
def enabled?, do: true
@impl true
def authorize_url(redirect_uri) do
{:ok,
%{
url:
"https://accounts.google.example/authorize?" <>
URI.encode_query(%{"redirect_uri" => redirect_uri, "state" => "google-test-state"}),
session_params: %{
state: "google-test-state",
nonce: "google-test-nonce",
code_verifier: "google-test-verifier"
}
}}
end
@impl true
def callback(_redirect_uri, params, %{state: state}) do
cond do
params["state"] != state ->
{:error, :invalid_state}
params["code"] in [nil, ""] ->
{:error, :missing_code}
params["email_verified"] == "false" ->
{:error, :email_not_verified}
true ->
email = params["email"] || "google-user@example.com"
{:ok,
%{
provider_uid: params["uid"] || "google-user-42",
email: email,
email_verified: true,
display_name: params["name"] || "Google Neighbor"
}}
end
end
end

View File

@ -0,0 +1,71 @@
defmodule WhoNeedHelp.GoogleAuthTest do
use ExUnit.Case, async: true
alias WhoNeedHelp.GoogleAuth.AssentAdapter
test "Google authorization uses OIDC state, nonce, PKCE, and identity-only scopes" do
nonce = "session-bound-nonce"
assert {:ok, %{url: url, session_params: session_params}} =
Assent.Strategy.Google.authorize_url(
client_id: "client",
client_secret: "secret",
redirect_uri: "https://example.test/auth/google/callback",
nonce: nonce,
code_verifier: true,
openid_configuration: %{
"authorization_endpoint" => "https://accounts.google.test/o/oauth2/v2/auth"
}
)
uri = URI.parse(url)
params = URI.decode_query(uri.query)
assert uri.host == "accounts.google.test"
assert params["redirect_uri"] == "https://example.test/auth/google/callback"
assert params["scope"] == "openid email profile"
assert params["state"] == session_params.state
assert params["nonce"] == nonce
assert session_params.nonce == nonce
assert params["code_challenge_method"] == "S256"
assert is_binary(session_params.code_verifier)
refute Map.has_key?(params, "access_type")
end
test "normalizes only a verified Google identity and discards token-shaped claims" do
assert {:ok, identity} =
AssentAdapter.normalize_identity(%{
"sub" => "google-subject-123",
"email" => " Alice@Example.COM ",
"email_verified" => true,
"name" => "Alice Neighbor",
"access_token" => "must-not-leak",
"id_token" => "must-not-leak"
})
assert identity == %{
provider_uid: "google-subject-123",
email: "alice@example.com",
email_verified: true,
display_name: "Alice Neighbor"
}
refute Map.has_key?(identity, :access_token)
refute Map.has_key?(identity, :id_token)
end
test "rejects an unverified or incomplete Google email" do
assert {:error, :email_not_verified} =
AssentAdapter.normalize_identity(%{
"sub" => "subject",
"email" => "alice@example.com",
"email_verified" => false
})
assert {:error, :invalid_provider_identity} =
AssentAdapter.normalize_identity(%{
"sub" => "subject",
"email_verified" => true
})
end
end

View File

@ -0,0 +1,263 @@
defmodule WhoNeedHelpWeb.GoogleAuthControllerTest do
use WhoNeedHelpWeb.ConnCase, async: false
alias WhoNeedHelp.Accounts
alias WhoNeedHelp.Accounts.AuthIdentity
alias WhoNeedHelp.Repo
alias WhoNeedHelp.Trust.AuditEvent
import WhoNeedHelp.AccountsFixtures
test "starts a login flow with callback state, nonce, and PKCE session data", %{conn: conn} do
conn = post(conn, ~p"/auth/google/login")
assert redirected_to(conn) =~ "https://accounts.google.example/authorize?"
assert %{
"flow" => "login",
"session_params" => %{
state: "google-test-state",
nonce: "google-test-nonce",
code_verifier: "google-test-verifier"
}
} = get_session(conn, "google_auth_flow")
end
test "requires adult and safety consent before starting Google registration", %{conn: conn} do
conn =
post(conn, ~p"/auth/google/register", %{
"google_registration" => %{"locale" => "en", "terms_accepted" => "false"}
})
assert redirected_to(conn) == ~p"/users/register"
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "18 or older"
assert is_nil(get_session(conn, "google_auth_flow"))
end
test "registers, confirms, links, and logs in a new verified Google user", %{conn: conn} do
email = unique_user_email()
conn =
conn
|> post(~p"/auth/google/register", %{
"google_registration" => %{"locale" => "ru", "terms_accepted" => "true"}
})
|> recycle()
|> get(
~p"/auth/google/callback?code=one-time-code&state=google-test-state&uid=google-123&email=#{email}&name=Google%20Helper"
)
assert redirected_to(conn) == ~p"/"
assert get_session(conn, :user_token)
assert is_nil(get_session(conn, "google_auth_flow"))
user = Accounts.get_user_by_email(email)
assert user.confirmed_at
assert user.accepted_terms_at
assert user.locale == "ru"
assert user.display_name == "Google Helper"
assert is_nil(user.hashed_password)
assert %AuthIdentity{
provider: :google,
provider_uid: "google-123",
email: ^email,
user_id: user_id
} = Repo.get_by(AuthIdentity, provider: :google, provider_uid: "google-123")
assert user_id == user.id
end
test "does not automatically merge an existing email account", %{conn: conn} do
user = user_fixture()
conn =
conn
|> post(~p"/auth/google/register", %{
"google_registration" => %{"locale" => "en", "terms_accepted" => "true"}
})
|> recycle()
|> get(
~p"/auth/google/callback?code=code&state=google-test-state&uid=new-google&email=#{user.email}"
)
assert redirected_to(conn) == ~p"/users/log-in"
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "already exists"
refute get_session(conn, :user_token)
refute Repo.get_by(AuthIdentity, provider: :google, provider_uid: "new-google")
end
test "logs in only through an identity already linked to the local account", %{conn: conn} do
user = user_fixture()
assert {:ok, _identity} =
Accounts.link_google_identity(user, %{
provider_uid: "returning-google",
email: user.email,
email_verified: true,
display_name: user.display_name
})
conn =
conn
|> post(~p"/auth/google/login")
|> recycle()
|> get(
~p"/auth/google/callback?code=code&state=google-test-state&uid=returning-google&email=#{user.email}"
)
assert redirected_to(conn) == ~p"/"
assert get_session(conn, :user_token)
end
test "does not create an account from the login-only flow", %{conn: conn} do
email = unique_user_email()
conn =
conn
|> post(~p"/auth/google/login")
|> recycle()
|> get(
~p"/auth/google/callback?code=code&state=google-test-state&uid=unknown-google&email=#{email}"
)
assert redirected_to(conn) == ~p"/users/log-in"
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "not connected"
refute Accounts.get_user_by_email(email)
refute get_session(conn, :user_token)
end
test "rejects unverified Google email and consumes the flow session", %{conn: conn} do
conn =
conn
|> post(~p"/auth/google/login")
|> recycle()
|> get(~p"/auth/google/callback?code=code&state=google-test-state&email_verified=false")
assert redirected_to(conn) == ~p"/users/log-in"
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "verified email"
assert is_nil(get_session(conn, "google_auth_flow"))
end
test "callback cannot be replayed", %{conn: conn} do
first =
conn
|> post(~p"/auth/google/login")
|> recycle()
|> get(~p"/auth/google/callback?code=code&state=google-test-state")
replay =
first
|> recycle()
|> get(~p"/auth/google/callback?code=code&state=google-test-state")
assert redirected_to(replay) == ~p"/users/log-in"
assert Phoenix.Flash.get(replay.assigns.flash, :error) =~ "session expired"
end
test "links Google only to the authenticated flow owner and records an audit event", %{
conn: conn
} do
user = user_fixture()
conn =
conn
|> log_in_user(user)
|> post(~p"/auth/google/link")
|> recycle()
|> get(
~p"/auth/google/callback?code=code&state=google-test-state&uid=linked-google&email=linked@example.com"
)
assert redirected_to(conn) == ~p"/users/settings"
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "connected"
identity = Repo.get_by!(AuthIdentity, provider: :google, provider_uid: "linked-google")
assert identity.user_id == user.id
assert %AuditEvent{actor_id: actor_id, target_id: target_id} =
Repo.get_by!(AuditEvent,
action: "auth_identity.connected",
target_id: identity.id
)
assert actor_id == user.id
assert target_id == identity.id
end
test "rejects a link callback after the authenticated account changes", %{conn: conn} do
first_user = user_fixture()
second_user = user_fixture()
conn =
conn
|> log_in_user(first_user)
|> post(~p"/auth/google/link")
|> recycle()
|> log_in_user(second_user)
|> get(~p"/auth/google/callback?code=code&state=google-test-state&uid=wrong-owner")
assert redirected_to(conn) == ~p"/users/log-in"
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "invalid"
refute Repo.get_by(AuthIdentity, provider: :google, provider_uid: "wrong-owner")
end
test "requires authentication to start account linking", %{conn: conn} do
conn = post(conn, ~p"/auth/google/link")
assert redirected_to(conn) == ~p"/users/log-in"
end
test "rejects a link callback after the sudo window expires", %{conn: conn} do
user = user_fixture()
conn =
conn
|> log_in_user(user)
|> post(~p"/auth/google/link")
token = get_session(conn, :user_token)
override_token_authenticated_at(
token,
DateTime.utc_now(:second) |> DateTime.add(-11, :minute)
)
conn =
conn
|> recycle()
|> get(~p"/auth/google/callback?code=code&state=google-test-state&uid=stale-google")
assert redirected_to(conn) == ~p"/users/log-in"
refute Repo.get_by(AuthIdentity, provider: :google, provider_uid: "stale-google")
end
test "disconnects Google in sudo mode and records the audit event", %{conn: conn} do
user = user_fixture()
assert {:ok, identity} =
Accounts.link_google_identity(user, %{
provider_uid: "disconnect-google",
email: user.email,
email_verified: true,
display_name: user.display_name
})
conn =
conn
|> log_in_user(user)
|> delete(~p"/auth/google/link")
assert redirected_to(conn) == ~p"/users/settings"
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "disconnected"
refute Repo.get(AuthIdentity, identity.id)
assert %AuditEvent{actor_id: actor_id, target_id: target_id} =
Repo.get_by!(AuditEvent,
action: "auth_identity.disconnected",
target_id: identity.id
)
assert actor_id == user.id
assert target_id == identity.id
end
end

View File

@ -12,6 +12,9 @@ defmodule WhoNeedHelpWeb.UserRegistrationControllerTest do
assert response =~ "Register"
assert response =~ ~p"/users/log-in"
assert response =~ ~p"/users/register"
assert response =~ "A password is not required"
assert response =~ "Sign up with Google"
assert response =~ ~p"/auth/google/register"
end
test "redirects if already logged in", %{conn: conn} do

View File

@ -16,7 +16,9 @@ defmodule WhoNeedHelpWeb.UserSessionControllerTest do
assert get_resp_header(conn, "cache-control") == ["no-store"]
assert response =~ "Log in"
assert response =~ ~p"/users/register"
assert response =~ "Log in with email"
assert response =~ "Email me a sign-in link"
assert response =~ "Sign in with Google"
assert response =~ "Use a password instead"
assert response =~ ~s(id="magic-link-fragment-form")
assert response =~ ~s(hidden)
end
@ -30,7 +32,7 @@ defmodule WhoNeedHelpWeb.UserSessionControllerTest do
assert html =~ "You need to reauthenticate"
refute html =~ "Register"
assert html =~ "Log in with email"
assert html =~ "Email me a sign-in link"
assert html =~
~s(<input type="email" name="user[email]" id="login_form_magic_email" value="#{user.email}")
@ -41,7 +43,7 @@ defmodule WhoNeedHelpWeb.UserSessionControllerTest do
response = html_response(conn, 200)
assert response =~ "Log in"
assert response =~ ~p"/users/register"
assert response =~ "Log in with email"
assert response =~ "Email me a sign-in link"
end
end

View File

@ -14,6 +14,8 @@ defmodule WhoNeedHelpWeb.UserSettingsControllerTest do
assert response =~ "Settings"
assert response =~ ~s(id="update_password_username")
assert response =~ ~s(autocomplete="username")
assert response =~ "Google sign-in"
assert response =~ "Connect Google account"
end
test "redirects if user is not logged in" do