feat: add verified GitHub social linking

This commit is contained in:
SimpleTest 2026-07-18 21:04:37 +03:00
parent 2a1a2d93eb
commit 307794c47c
26 changed files with 732 additions and 26 deletions

View File

@ -21,6 +21,11 @@ WNH_TRACKING_HTTP_TIMEOUT_MS=15000
# Public raster tile template used by MapLibre. Use a provider whose policy and
# capacity match the deployment before a public launch.
MAP_TILE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png
# Optional verified GitHub linking. Leave both empty until a GitHub OAuth App
# exists. Its callback URL must be:
# https://YOUR_PHX_HOST/auth/social/github/callback
GITHUB_OAUTH_CLIENT_ID=
GITHUB_OAUTH_CLIENT_SECRET=
POSTGRES_DB=who_need_help
POSTGRES_USER=postgres

View File

@ -37,7 +37,9 @@ thank-you link; money goes directly between users outside the platform.
positions are deleted on stop, terminal match state, or participant block.
- PostgreSQL-backed cross-replica action-limit policies configured by the
operator. No unapproved numeric thresholds are enabled by default.
- Public manual social links, always marked unverified in this MVP.
- Public manual social links, always marked unverified, plus optional verified
GitHub linking through a state- and PKCE-protected OAuth flow. Provider access
tokens are not stored.
- EN/UK/RU UI foundation and installable PWA metadata/service worker.
- Native Android WebView client with the same authenticated LiveView, map,
private chat, and a user-started location foreground service. Its persistent
@ -52,7 +54,7 @@ thank-you link; money goes directly between users outside the platform.
- Docker Compose and Helm/kind deployment paths with 2 web and 2 worker
replicas by default.
OAuth social verification, background PWA or unattended location tracking,
Additional social providers, background PWA or unattended location tracking,
platform payments, production Android signing/store publication, iOS,
automatic punitive fraud decisions, and jurisdiction-specific public-launch
policies are deliberately not claimed as complete.
@ -101,6 +103,22 @@ docker compose up -d --wait
This preserves the named PostgreSQL volume, but invalidates existing browser
sessions and changes handover codes for active local requests.
### Optional verified GitHub linking
Create a GitHub OAuth App with this exact callback URL for the active public
origin:
```text
https://YOUR_PHX_HOST/auth/social/github/callback
```
Set both `GITHUB_OAUTH_CLIENT_ID` and `GITHUB_OAUTH_CLIENT_SECRET` in the
ignored `.env` or deployment Secret, then recreate the application containers.
If both values are empty, the feature stays disabled and the profile explains
that state. A partial pair is rejected at startup. The flow requests only the
public GitHub identity, protects callbacks with state and PKCE, binds the flow
to the initiating signed-in user, and never persists the provider access token.
## Tests
The reproducible test command builds a dedicated test target and uses the
@ -171,9 +189,10 @@ credential fields.
For an external cluster, provide a real PostgreSQL/PostGIS service and a
pre-created Secret through required `existingSecret`; the chart never renders
credentials from tracked values. The Secret must contain `DATABASE_URL`,
`SECRET_KEY_BASE`, `HANDOVER_SECRET`, and `RELEASE_COOKIE`. The chart
intentionally has no invented CPU/RAM limits or HPA thresholds; measure this
application in the target environment before setting them.
`SECRET_KEY_BASE`, `HANDOVER_SECRET`, and `RELEASE_COOKIE`, and may additionally
contain both GitHub OAuth variables described above. The chart intentionally
has no invented CPU/RAM limits or HPA thresholds; measure this application in
the target environment before setting them.
## Local Codex category review

View File

@ -24,6 +24,8 @@ x-app-environment: &app-environment
CODEX_SESSION_ID: ${CODEX_SESSION_ID:-not-configured}
RATE_LIMIT_POLICIES_JSON: ${RATE_LIMIT_POLICIES_JSON:-{}}
MAP_TILE_URL: ${MAP_TILE_URL:-https://tile.openstreetmap.org/{z}/{x}/{y}.png}
GITHUB_OAUTH_CLIENT_ID: ${GITHUB_OAUTH_CLIENT_ID:-}
GITHUB_OAUTH_CLIENT_SECRET: ${GITHUB_OAUTH_CLIENT_SECRET:-}
services:
proxy:

View File

@ -23,6 +23,32 @@ config :who_need_help,
json -> Jason.decode!(json)
end)
github_oauth =
case {
System.get_env("GITHUB_OAUTH_CLIENT_ID"),
System.get_env("GITHUB_OAUTH_CLIENT_SECRET")
} do
{client_id, client_secret}
when is_binary(client_id) and client_id != "" and is_binary(client_secret) and
client_secret != "" ->
%{
github: [
client_id: client_id,
client_secret: client_secret
]
}
{client_id, client_secret} when client_id in [nil, ""] and client_secret in [nil, ""] ->
%{}
_partial_configuration ->
raise """
GITHUB_OAUTH_CLIENT_ID and GITHUB_OAUTH_CLIENT_SECRET must either both be set or both be empty.
"""
end
config :who_need_help, :social_oauth, github_oauth
if dns_query = System.get_env("DNS_CLUSTER_QUERY") do
config :who_need_help, :dns_cluster_query, dns_query
end

View File

@ -26,6 +26,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
# Disable swoosh api client as it is only required for production adapters
config :swoosh, :api_client, false

View File

@ -28,7 +28,9 @@ app:
smtpPort: "1025"
# Required. The Secret must contain DATABASE_URL, SECRET_KEY_BASE,
# HANDOVER_SECRET, and RELEASE_COOKIE.
# HANDOVER_SECRET, and RELEASE_COOKIE. It may also contain both
# GITHUB_OAUTH_CLIENT_ID and GITHUB_OAUTH_CLIENT_SECRET to enable verified
# GitHub linking.
existingSecret: ""
ingress:

View File

@ -46,6 +46,10 @@ are separate operational work and are not represented as complete.
- `Accounts`: users, authentication, social identities, privacy preferences,
blocks, and roles.
- `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
persistence layer.
- `Catalog`: category tree, proposals, votes, moderation decisions, and
translated labels.
- `Help`: requests, assignments, state transitions, handover codes, and

View File

@ -15,6 +15,7 @@ rebuilt deterministically.
| Phoenix LiveView | 1.2.7 |
| Ecto / Ecto SQL | 3.14.1 / 3.14.0 |
| Oban | 2.23.0 |
| Assent | 0.3.1 |
| Node.js LTS | 24.18.0 |
| npm build tool | 12.0.1 |
| Tailwind CSS binary | 4.3.3 |

View File

@ -65,6 +65,8 @@ helper per request. Every state transition is authorized and recorded.
matched chat, plus a user-started native location foreground service with a
persistent Stop notification; reproducible debug APK build.
- Admin-only local Codex moderation batch for category proposals.
- Manual public social links plus optional verified GitHub linking. Manual
links never receive a verified badge, and OAuth access tokens are not stored.
## Explicitly outside the MVP
@ -75,8 +77,8 @@ 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 and automated social-network identity checks. The MVP
supports manually attached public links and marks them unverified.
- OAuth verification for providers other than GitHub and automated
social-network identity scoring.
- Automatic punitive abuse enforcement and unapproved production thresholds.
- Claims that identity, safety, or fraud prevention is perfect.
@ -89,9 +91,10 @@ secured incident support. Each category adds validated text, select, and
boolean fields through its stored schema without changing the request form or
core state machine. Moderated proposals can extend the tree further.
Social links are user-provided references and are visibly marked unverified.
The data model reserves `verified_at` for a future OAuth flow, but the current
MVP does not set it.
User-provided social links are visibly marked unverified. A separately
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.
## Privacy settings

View File

@ -54,8 +54,16 @@ only for completed matched requests.
## Identity and social links
Email ownership can be confirmed through a magic link. Manually attached
Instagram, Facebook, Telegram, Google, or other URLs are labelled “unverified.”
OAuth verification is not implemented in the current MVP.
GitHub, Instagram, Facebook, Telegram, Google, or other URLs are labelled
“unverified.” If the operator configures a GitHub OAuth App, a user can instead
complete a state- and PKCE-protected authorization flow and receive a verified
GitHub badge. The flow is bound to the initiating signed-in user, each GitHub
account can belong to at most one local account, and the provider access token
is discarded after the public profile is read.
No other social provider is currently verified. A verified GitHub profile is
one trust signal, not government identity verification or proof of real-world
safety.
The MVP uses 18+ self-attestation. It does not imply government identity
verification.

View File

@ -16,15 +16,17 @@ 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 | Implemented as manual links | Public links can be attached and are visibly labelled unverified. | OAuth/social-network verification is not implemented. |
| Social profiles | Manual links implemented; 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; 146 tests pass, including callback replay/state checks. No access-token field exists and the controller receives only normalized identity attributes. | The staging operator has not supplied GitHub OAuth credentials, so the real external provider redirect/callback remains disabled and has not been browser-verified. 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 | Debug client implemented and emulator-verified | The native package `org.whoneedhelp.mobile.debug` launches the same authenticated LiveView app. Login, map, chat, permission prompts, minimized foreground-service location updates, notification Stop, and server cleanup were exercised on API 37. | Production signing, a public production origin, Play Store publication, unattended/background-permission tracking, and iOS are not implemented. |
| Multiple web/worker instances | Implemented and locally verified | Docker Compose and kind each ran 2 web and 2 worker replicas. The project probes cross-node Phoenix PubSub using different BEAM nodes. Kubernetes web/worker pods were Ready with zero restarts at the final observation. | Local PostGIS is a single instance. Production database HA, backups, and recovery are operator work and are not claimed complete. |
## Reproducible checks
- `./scripts/test.sh`: 136 tests, 0 failures after scoped Activity reporting
- `./scripts/test.sh`: 146 tests, 0 failures after verified GitHub linking
on Elixir 1.20.2 and Erlang/OTP 29.0.3.
- `mix compile --force --warnings-as-errors` and
`mix format --check-formatted`: passed after the OAuth change.
- The generated Activity migration was rolled back by exactly one step and
migrated forward again against `who_need_help_test`; both directions passed.
- The Activity-report migration was also rolled back and migrated forward. The
@ -142,6 +144,8 @@ environment.
After the Activity rollout, headed Chrome rendered the updated public
navigation, followed the Activity link to the authenticated route, and received
the expected login redirect and flash with zero console errors or warnings.
The currently deployed staging revision does not yet include the OAuth change;
deployment and a browser check are recorded only after that rollout occurs.
## Known work before a public production launch
@ -155,7 +159,6 @@ the expected login redirect and flash with zero console errors or warnings.
chat, moderation, or leaderboard result sets require it.
- Publish jurisdiction-specific emergency contacts, privacy, retention,
prohibited-items, and voluntary-payment guidance after legal review.
- Add OAuth identity verification only if the product chooses to make verified
social accounts a trust signal.
- Extend scoped reporting/moderation evidence to Activity entities without
exposing unrelated group conversations.
- Create and configure a GitHub OAuth App, then exercise the real external
provider redirect/callback in a headed browser. Until then staging keeps the
provider disabled and manual links remain unverified.

View File

@ -204,6 +204,37 @@ defmodule WhoNeedHelp.Accounts do
|> Repo.insert()
end
def upsert_verified_social_identity(
%User{id: user_id},
%{provider: provider, provider_uid: provider_uid} = attrs
) do
Repo.transact(fn ->
existing =
SocialIdentity
|> where(
[identity],
identity.provider == ^provider and identity.provider_uid == ^provider_uid
)
|> lock("FOR UPDATE")
|> Repo.one()
case existing do
%SocialIdentity{user_id: ^user_id} = identity ->
identity
|> SocialIdentity.verified_changeset(Map.put(attrs, :user_id, user_id))
|> Repo.update()
%SocialIdentity{} ->
{:error, :already_linked}
nil ->
%SocialIdentity{}
|> SocialIdentity.verified_changeset(Map.put(attrs, :user_id, user_id))
|> Repo.insert()
end
end)
end
def delete_social_identity(%User{id: user_id}, identity_id) do
case Repo.get_by(SocialIdentity, id: identity_id, user_id: user_id) do
nil -> {:error, :not_found}

View File

@ -6,7 +6,8 @@ defmodule WhoNeedHelp.Accounts.SocialIdentity do
@foreign_key_type :binary_id
schema "social_identities" do
field :provider, Ecto.Enum, values: [:google, :instagram, :facebook, :telegram, :other]
field :provider, Ecto.Enum,
values: [:github, :google, :instagram, :facebook, :telegram, :other]
field :provider_uid, :string
field :profile_url, :string
@ -27,4 +28,31 @@ defmodule WhoNeedHelp.Accounts.SocialIdentity do
|> validate_length(:handle, max: 100)
|> unique_constraint([:provider, :provider_uid])
end
def verified_changeset(identity, attrs) do
identity
|> cast(attrs, [
:provider,
:provider_uid,
:profile_url,
:handle,
:verified_at,
:user_id
])
|> validate_required([
:provider,
:provider_uid,
:profile_url,
:verified_at,
:user_id
])
|> validate_inclusion(:provider, [:github])
|> validate_format(:profile_url, ~r/^https:\/\/github\.com\/[^\s\/]+\/?$/i,
message: "must be a GitHub profile URL"
)
|> validate_length(:provider_uid, max: 255)
|> validate_length(:profile_url, max: 500)
|> validate_length(:handle, max: 100)
|> unique_constraint([:provider, :provider_uid])
end
end

View File

@ -0,0 +1,43 @@
defmodule WhoNeedHelp.SocialOAuth do
@moduledoc """
Provider-neutral boundary for verifying social identities.
OAuth access tokens are consumed by the configured adapter and are never
returned to controllers or persisted by this application.
"""
@type provider :: :github
@type session_params :: map()
@type verified_identity :: %{
provider: provider(),
provider_uid: String.t(),
profile_url: String.t(),
handle: String.t() | nil,
verified_at: DateTime.t()
}
@callback enabled?(provider()) :: boolean()
@callback authorize_url(provider(), String.t()) ::
{:ok, %{url: String.t(), session_params: session_params()}} | {:error, term()}
@callback callback(provider(), String.t(), map(), session_params()) ::
{:ok, verified_identity()} | {:error, term()}
def provider("github"), do: {:ok, :github}
def provider(_provider), do: {:error, :unsupported_provider}
def enabled?(provider), do: adapter().enabled?(provider)
def authorize_url(provider, redirect_uri),
do: adapter().authorize_url(provider, redirect_uri)
def callback(provider, redirect_uri, params, session_params),
do: adapter().callback(provider, redirect_uri, params, session_params)
defp adapter do
Application.get_env(
:who_need_help,
:social_oauth_adapter,
WhoNeedHelp.SocialOAuth.AssentAdapter
)
end
end

View File

@ -0,0 +1,59 @@
defmodule WhoNeedHelp.SocialOAuth.AssentAdapter do
@moduledoc false
@behaviour WhoNeedHelp.SocialOAuth
alias WhoNeedHelp.SocialOAuth.GithubStrategy
@impl true
def enabled?(:github), do: not is_nil(provider_config(:github))
@impl true
def authorize_url(:github, redirect_uri) do
with {:ok, config} <- config(:github, redirect_uri) do
GithubStrategy.authorize_url(config)
end
end
@impl true
def callback(:github, redirect_uri, params, session_params) do
with {:ok, config} <- config(:github, redirect_uri),
{:ok, %{user: user}} <-
config
|> Keyword.put(:session_params, session_params)
|> GithubStrategy.callback(params),
{:ok, identity} <- normalize_github_identity(user) do
{:ok, identity}
end
end
defp config(provider, redirect_uri) do
case provider_config(provider) do
nil -> {:error, :provider_disabled}
config -> {:ok, Keyword.put(config, :redirect_uri, redirect_uri)}
end
end
defp provider_config(provider) do
:who_need_help
|> Application.get_env(:social_oauth, %{})
|> Map.get(provider)
end
defp normalize_github_identity(%{
"sub" => uid,
"preferred_username" => handle
})
when not is_nil(uid) and is_binary(handle) and handle != "" do
{:ok,
%{
provider: :github,
provider_uid: to_string(uid),
profile_url: "https://github.com/#{handle}",
handle: "@#{handle}",
verified_at: DateTime.utc_now(:second)
}}
end
defp normalize_github_identity(_user), do: {:error, :invalid_provider_identity}
end

View File

@ -0,0 +1,30 @@
defmodule WhoNeedHelp.SocialOAuth.GithubStrategy do
@moduledoc false
use Assent.Strategy.OAuth2.Base
@impl true
def default_config(_config) do
[
base_url: "https://api.github.com",
authorize_url: "https://github.com/login/oauth/authorize",
token_url: "https://github.com/login/oauth/access_token",
user_url: "/user",
auth_method: :client_secret_post,
code_verifier: true,
authorization_params: []
]
end
@impl true
def normalize(_config, user) do
{:ok,
%{
"sub" => user["id"],
"name" => user["name"],
"preferred_username" => user["login"],
"profile" => user["html_url"],
"picture" => user["avatar_url"]
}}
end
end

View File

@ -0,0 +1,108 @@
defmodule WhoNeedHelpWeb.SocialOAuthController do
use WhoNeedHelpWeb, :controller
require Logger
alias WhoNeedHelp.{Accounts, SocialOAuth, Trust}
def request(conn, %{"provider" => provider_param}) do
user = conn.assigns.current_scope.user
with {:ok, provider} <- SocialOAuth.provider(provider_param),
true <- SocialOAuth.enabled?(provider),
redirect_uri <- callback_url(provider),
{:ok, %{url: url, session_params: session_params}} <-
SocialOAuth.authorize_url(provider, redirect_uri) do
conn
|> put_session(session_key(provider), %{
"user_id" => user.id,
"session_params" => session_params
})
|> redirect(external: url)
else
false ->
oauth_error(conn, "That verification provider is not configured.")
{:error, :unsupported_provider} ->
oauth_error(conn, "That verification provider is not supported.")
{:error, error} ->
Logger.warning("Unable to start social OAuth (#{error_name(error)})")
oauth_error(conn, "Social verification could not be started.")
end
end
def callback(conn, %{"provider" => provider_param} = params) do
user = conn.assigns.current_scope.user
with {:ok, provider} <- SocialOAuth.provider(provider_param),
%{"user_id" => user_id, "session_params" => session_params} <-
get_session(conn, session_key(provider)),
true <- user_id == user.id,
redirect_uri <- callback_url(provider),
{:ok, identity_attrs} <-
SocialOAuth.callback(provider, redirect_uri, params, session_params),
{:ok, identity} <-
Accounts.upsert_verified_social_identity(user, identity_attrs) do
audit_social_verification(user.id, identity)
conn
|> delete_session(session_key(provider))
|> put_flash(:info, "GitHub profile verified.")
|> redirect(to: ~p"/profile")
else
nil ->
callback_error(conn, provider_param, "Social verification session expired. Please retry.")
false ->
callback_error(conn, provider_param, "Social verification session is invalid.")
{:error, :already_linked} ->
callback_error(conn, provider_param, "That social account is already linked.")
{:error, error} ->
Logger.warning("Social OAuth callback failed (#{error_name(error)})")
callback_error(conn, provider_param, "Social verification failed. Please retry.")
end
end
defp callback_url(provider) do
url(~p"/auth/social/#{provider}/callback")
end
defp session_key(provider), do: "social_oauth_#{provider}"
defp callback_error(conn, provider, message) do
conn
|> delete_session("social_oauth_#{provider}")
|> oauth_error(message)
end
defp oauth_error(conn, message) do
conn
|> put_flash(:error, message)
|> redirect(to: ~p"/profile")
end
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 audit_social_verification(user_id, identity) do
case Trust.audit(
user_id,
"social_identity.verified",
"social_identity",
identity.id,
%{"provider" => to_string(identity.provider)}
) do
{:ok, _event} ->
:ok
{:error, error} ->
Logger.error(
"Verified social identity #{identity.id}, but audit insertion failed: #{inspect(error)}"
)
end
end
end

View File

@ -265,6 +265,29 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
<dt class="text-xs text-base-content/50">Organizer</dt>
<dd class="font-semibold">{@activity.creator.display_name || "Community member"}</dd>
</div>
<div
:if={@activity.creator.social_identities != []}
class="sm:col-span-2"
>
<dt class="text-xs text-base-content/50">Organizer social links</dt>
<dd class="mt-2 flex flex-wrap gap-2">
<a
:for={identity <- @activity.creator.social_identities}
href={identity.profile_url}
target="_blank"
rel="noopener noreferrer nofollow"
class={[
"badge badge-outline",
identity.verified_at && "badge-success",
is_nil(identity.verified_at) && "badge-warning"
]}
>
{identity.provider} · {if identity.verified_at,
do: "verified",
else: "unverified"}
</a>
</dd>
</div>
</dl>
<div :if={map_size(@activity.structured_data) > 0} class="mt-5">
@ -378,6 +401,26 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
class="rounded-xl bg-base-200 p-3"
>
<div class="font-semibold">{participant.user.display_name || "Community member"}</div>
<div
:if={participant.user.social_identities != []}
class="mt-2 flex flex-wrap gap-1"
>
<a
:for={identity <- participant.user.social_identities}
href={identity.profile_url}
target="_blank"
rel="noopener noreferrer nofollow"
class={[
"badge badge-xs badge-outline",
identity.verified_at && "badge-success",
is_nil(identity.verified_at) && "badge-warning"
]}
>
{identity.provider} · {if identity.verified_at,
do: "verified",
else: "unverified"}
</a>
</div>
<div class="mt-2 flex gap-2">
<button
phx-click="approve"
@ -417,6 +460,26 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
<span :if={participant.role == :organizer} class="badge badge-xs ml-1">
organizer
</span>
<div
:if={participant.user.social_identities != []}
class="mt-1 flex flex-wrap gap-1"
>
<a
:for={identity <- participant.user.social_identities}
href={identity.profile_url}
target="_blank"
rel="noopener noreferrer nofollow"
class={[
"badge badge-xs badge-outline",
identity.verified_at && "badge-success",
is_nil(identity.verified_at) && "badge-warning"
]}
>
{identity.provider} · {if identity.verified_at,
do: "verified",
else: "unverified"}
</a>
</div>
</li>
</ul>
</section>

View File

@ -2,6 +2,7 @@ defmodule WhoNeedHelpWeb.ProfileLive do
use WhoNeedHelpWeb, :live_view
alias WhoNeedHelp.Accounts
alias WhoNeedHelp.SocialOAuth
alias WhoNeedHelp.Trust
@impl true
@ -14,6 +15,7 @@ defmodule WhoNeedHelpWeb.ProfileLive do
|> assign(:reputation, Trust.reputation(user.id))
|> assign(:reviews, Trust.visible_reviews(user.id))
|> assign(:blocks, Trust.list_blocks(socket.assigns.current_scope))
|> assign(:github_oauth_enabled, SocialOAuth.enabled?(:github))
|> assign(:form, to_form(Accounts.change_user_profile(user)))
|> assign_social_identities(user)}
end
@ -169,12 +171,27 @@ defmodule WhoNeedHelpWeb.ProfileLive do
<div>
<h2 class="text-xl font-bold">Social links</h2>
<p class="mt-1 text-sm text-base-content/55">
Manually added links are public and marked unverified. OAuth verification is not
enabled in this MVP.
Manually added links are public and marked unverified. A verified badge is only
granted after completing a provider authorization flow.
</p>
</div>
<.link
:if={@github_oauth_enabled}
href={~p"/auth/social/github"}
class="btn btn-primary btn-sm"
>
Verify GitHub
</.link>
</div>
<p
:if={!@github_oauth_enabled}
class="mt-4 rounded-2xl bg-base-200 p-4 text-sm text-base-content/60"
>
GitHub verification is unavailable until the operator configures its OAuth
credentials. No manually entered link is treated as verified.
</p>
<div class="mt-5 grid gap-4 md:grid-cols-[1fr_.9fr]">
<div class="space-y-3">
<p :if={@social_identities == []} class="text-sm text-base-content/55">
@ -221,6 +238,7 @@ defmodule WhoNeedHelpWeb.ProfileLive do
type="select"
label="Network"
options={[
{"GitHub", "github"},
{"Instagram", "instagram"},
{"Facebook", "facebook"},
{"Telegram", "telegram"},

View File

@ -85,6 +85,8 @@ defmodule WhoNeedHelpWeb.Router do
get "/users/settings", UserSettingsController, :edit
put "/users/settings", UserSettingsController, :update
get "/users/settings/confirm-email/:token", UserSettingsController, :confirm_email
get "/auth/social/:provider", SocialOAuthController, :request
get "/auth/social/:provider/callback", SocialOAuthController, :callback
end
scope "/", WhoNeedHelpWeb do

View File

@ -71,6 +71,7 @@ defmodule WhoNeedHelp.MixProject do
{:swoosh, "~> 1.16"},
{:gen_smtp, "~> 1.3"},
{:req, "~> 0.5"},
{:assent, "~> 0.3.1"},
{:telemetry_metrics, "~> 1.0"},
{:telemetry_poller, "~> 1.0"},
{:gettext, "~> 1.0"},

View File

@ -1,4 +1,5 @@
%{
"assent": {:hex, :assent, "0.3.1", "7e7b04f4ab5d07b497b80c04f009a0f1efc409787e68b8e086512b9098f3095b", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "3597b31f9eb556d97e64cf60c00d3451f7353d7b465a71d33530b870ebed1ff1"},
"bandit": {:hex, :bandit, "1.12.0", "6c5214daa2469644ac4ab0113b98abc24f75e348378e6a974c6343b3e5da22ef", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "45dac82dc86f45cf4a196dee9cc5a8b791d9c9469d996055f055e6ee36c66e20"},
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},

View File

@ -0,0 +1,40 @@
defmodule WhoNeedHelp.SocialOAuthFake do
@behaviour WhoNeedHelp.SocialOAuth
@impl true
def enabled?(:github), do: true
@impl true
def authorize_url(:github, redirect_uri) do
{:ok,
%{
url:
"https://github.example/authorize?" <>
URI.encode_query(%{"redirect_uri" => redirect_uri, "state" => "test-state"}),
session_params: %{state: "test-state", code_verifier: "test-verifier"}
}}
end
@impl true
def callback(:github, _redirect_uri, params, %{state: state}) do
cond do
params["state"] != state ->
{:error, :invalid_state}
params["code"] in [nil, ""] ->
{:error, :missing_code}
true ->
handle = params["login"] || "helpful-neighbor"
{:ok,
%{
provider: :github,
provider_uid: params["uid"] || "github-user-42",
profile_url: "https://github.com/#{handle}",
handle: "@#{handle}",
verified_at: ~U[2026-07-18 18:00:00Z]
}}
end
end
end

View File

@ -0,0 +1,43 @@
defmodule WhoNeedHelp.SocialOAuthTest do
use ExUnit.Case, async: true
alias WhoNeedHelp.SocialOAuth.GithubStrategy
test "GitHub authorization uses state and PKCE without requesting extra scopes" do
assert {:ok, %{url: url, session_params: session_params}} =
GithubStrategy.authorize_url(
client_id: "client",
client_secret: "secret",
redirect_uri: "https://example.test/auth/social/github/callback"
)
uri = URI.parse(url)
params = URI.decode_query(uri.query)
assert uri.host == "github.com"
assert params["client_id"] == "client"
assert params["redirect_uri"] == "https://example.test/auth/social/github/callback"
assert params["state"] == session_params.state
assert params["code_challenge_method"] == "S256"
assert is_binary(session_params.code_verifier)
assert is_binary(session_params.code_challenge)
refute Map.has_key?(params, "scope")
end
test "normalizes only the public GitHub identity fields" do
assert {:ok, normalized} =
GithubStrategy.normalize([], %{
"id" => 123,
"login" => "alice",
"name" => "Alice",
"html_url" => "https://github.com/alice",
"avatar_url" => "https://avatars.githubusercontent.com/u/123",
"access_token" => "must-not-leak"
})
assert normalized["sub"] == 123
assert normalized["preferred_username"] == "alice"
refute Map.has_key?(normalized, "access_token")
refute Map.has_key?(normalized, "email")
end
end

View File

@ -0,0 +1,143 @@
defmodule WhoNeedHelpWeb.SocialOAuthControllerTest do
use WhoNeedHelpWeb.ConnCase, async: false
alias WhoNeedHelp.{Accounts, Repo}
alias WhoNeedHelp.Trust.AuditEvent
setup :register_and_log_in_user
test "requires an authenticated user" do
conn = get(build_conn(), ~p"/auth/social/github")
assert redirected_to(conn) == ~p"/users/log-in"
end
test "starts a user-bound authorization session", %{conn: conn, user: user} do
conn = get(conn, ~p"/auth/social/github")
assert redirected_to(conn) =~ "https://github.example/authorize?"
assert %{
"user_id" => user_id,
"session_params" => %{state: "test-state", code_verifier: "test-verifier"}
} = get_session(conn, "social_oauth_github")
assert user_id == user.id
end
test "rejects unsupported providers", %{conn: conn} do
conn = get(conn, "/auth/social/not-a-provider")
assert redirected_to(conn) == ~p"/profile"
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "not supported"
end
test "links a verified identity without persisting the access token", %{
conn: conn,
user: user
} do
conn =
conn
|> get(~p"/auth/social/github")
|> recycle()
|> get(
~p"/auth/social/github/callback?code=one-time-code&state=test-state&uid=123&login=alice"
)
assert redirected_to(conn) == ~p"/profile"
assert Phoenix.Flash.get(conn.assigns.flash, :info) == "GitHub profile verified."
assert is_nil(get_session(conn, "social_oauth_github"))
assert [identity] = Accounts.list_social_identities(user)
assert identity.provider == :github
assert identity.provider_uid == "123"
assert identity.profile_url == "https://github.com/alice"
assert identity.handle == "@alice"
assert identity.verified_at == ~U[2026-07-18 18:00:00Z]
refute Map.has_key?(Map.from_struct(identity), :access_token)
refute inspect(identity) =~ "one-time-code"
assert %AuditEvent{
actor_id: actor_id,
action: "social_identity.verified",
target_id: target_id
} =
Repo.get_by(AuditEvent,
action: "social_identity.verified",
target_id: identity.id
)
assert actor_id == user.id
assert target_id == identity.id
end
test "callback cannot be replayed", %{conn: conn} do
conn =
conn
|> get(~p"/auth/social/github")
|> recycle()
|> get(~p"/auth/social/github/callback?code=code&state=test-state")
replay =
conn
|> recycle()
|> get(~p"/auth/social/github/callback?code=code&state=test-state")
assert redirected_to(replay) == ~p"/profile"
assert Phoenix.Flash.get(replay.assigns.flash, :error) =~ "session expired"
end
@tag :capture_log
test "rejects a callback state mismatch and clears the flow session", %{conn: conn} do
conn =
conn
|> get(~p"/auth/social/github")
|> recycle()
|> get(~p"/auth/social/github/callback?code=code&state=wrong")
assert redirected_to(conn) == ~p"/profile"
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "failed"
assert is_nil(get_session(conn, "social_oauth_github"))
end
test "rejects a flow bound to another local user", %{conn: conn} do
other_user = WhoNeedHelp.AccountsFixtures.user_fixture()
conn =
conn
|> put_session("social_oauth_github", %{
"user_id" => other_user.id,
"session_params" => %{state: "test-state", code_verifier: "test-verifier"}
})
|> get(~p"/auth/social/github/callback?code=code&state=test-state")
assert redirected_to(conn) == ~p"/profile"
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "invalid"
assert is_nil(get_session(conn, "social_oauth_github"))
end
test "does not let two users claim the same provider account", %{
conn: first_conn,
user: first_user
} do
first_conn
|> get(~p"/auth/social/github")
|> recycle()
|> get(~p"/auth/social/github/callback?code=first&state=test-state&uid=shared")
second_user = WhoNeedHelp.AccountsFixtures.user_fixture()
second_conn =
build_conn()
|> log_in_user(second_user)
|> get(~p"/auth/social/github")
|> recycle()
|> get(~p"/auth/social/github/callback?code=second&state=test-state&uid=shared")
assert redirected_to(second_conn) == ~p"/profile"
assert Phoenix.Flash.get(second_conn.assigns.flash, :error) =~ "already linked"
assert [_identity] = Accounts.list_social_identities(first_user)
assert [] == Accounts.list_social_identities(second_user)
end
end

View File

@ -4,7 +4,7 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
import Phoenix.LiveViewTest
import WhoNeedHelp.AccountsFixtures
alias WhoNeedHelp.{Activities, Catalog, Help, Messaging, Trust}
alias WhoNeedHelp.{Accounts, Activities, Catalog, Help, Messaging, Trust}
alias WhoNeedHelp.Repo
setup :register_and_log_in_user
@ -92,12 +92,29 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
test "organizer approves an activity participant and group chat updates live", %{
conn: organizer_conn,
scope: organizer_scope
scope: organizer_scope,
user: organizer
} do
Catalog.seed_defaults()
participant = user_fixture(display_name: "Activity participant")
participant_conn = build_conn() |> log_in_user(participant)
{:ok, _manual_link} =
Accounts.add_social_identity(organizer, %{
"provider" => "telegram",
"profile_url" => "https://t.me/activity_organizer",
"handle" => "@activity_organizer"
})
{:ok, _verified_link} =
Accounts.upsert_verified_social_identity(participant, %{
provider: :github,
provider_uid: "activity-participant",
profile_url: "https://github.com/activity-participant",
handle: "@activity-participant",
verified_at: ~U[2026-07-18 18:00:00Z]
})
coffee =
Catalog.list_categories(:activity)
|> Enum.find(&(&1.slug == "coffee-meetup"))
@ -123,6 +140,7 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
assert html =~ "Safety controls"
assert html =~ "Block organizer"
assert html =~ "Send report"
assert html =~ "telegram · unverified"
refute html =~ "Approved group chat"
participant_view
@ -130,13 +148,17 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|> render_click()
assert render(participant_view) =~ "Approval pending"
assert render(organizer_view) =~ "Activity participant"
organizer_html = render(organizer_view)
assert organizer_html =~ "Activity participant"
assert organizer_html =~ "github · verified"
organizer_view
|> element("button[phx-click='approve']")
|> render_click()
assert render(participant_view) =~ "Approved group chat"
participant_html = render(participant_view)
assert participant_html =~ "Approved group chat"
assert participant_html =~ "github · verified"
html =
participant_view