feat: add verified GitHub social linking
This commit is contained in:
parent
2a1a2d93eb
commit
307794c47c
|
|
@ -21,6 +21,11 @@ WNH_TRACKING_HTTP_TIMEOUT_MS=15000
|
||||||
# Public raster tile template used by MapLibre. Use a provider whose policy and
|
# Public raster tile template used by MapLibre. Use a provider whose policy and
|
||||||
# capacity match the deployment before a public launch.
|
# capacity match the deployment before a public launch.
|
||||||
MAP_TILE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
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_DB=who_need_help
|
||||||
POSTGRES_USER=postgres
|
POSTGRES_USER=postgres
|
||||||
|
|
|
||||||
29
README.md
29
README.md
|
|
@ -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.
|
positions are deleted on stop, terminal match state, or participant block.
|
||||||
- PostgreSQL-backed cross-replica action-limit policies configured by the
|
- PostgreSQL-backed cross-replica action-limit policies configured by the
|
||||||
operator. No unapproved numeric thresholds are enabled by default.
|
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.
|
- EN/UK/RU UI foundation and installable PWA metadata/service worker.
|
||||||
- Native Android WebView client with the same authenticated LiveView, map,
|
- Native Android WebView client with the same authenticated LiveView, map,
|
||||||
private chat, and a user-started location foreground service. Its persistent
|
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
|
- Docker Compose and Helm/kind deployment paths with 2 web and 2 worker
|
||||||
replicas by default.
|
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,
|
platform payments, production Android signing/store publication, iOS,
|
||||||
automatic punitive fraud decisions, and jurisdiction-specific public-launch
|
automatic punitive fraud decisions, and jurisdiction-specific public-launch
|
||||||
policies are deliberately not claimed as complete.
|
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
|
This preserves the named PostgreSQL volume, but invalidates existing browser
|
||||||
sessions and changes handover codes for active local requests.
|
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
|
## Tests
|
||||||
|
|
||||||
The reproducible test command builds a dedicated test target and uses the
|
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
|
For an external cluster, provide a real PostgreSQL/PostGIS service and a
|
||||||
pre-created Secret through required `existingSecret`; the chart never renders
|
pre-created Secret through required `existingSecret`; the chart never renders
|
||||||
credentials from tracked values. The Secret must contain `DATABASE_URL`,
|
credentials from tracked values. The Secret must contain `DATABASE_URL`,
|
||||||
`SECRET_KEY_BASE`, `HANDOVER_SECRET`, and `RELEASE_COOKIE`. The chart
|
`SECRET_KEY_BASE`, `HANDOVER_SECRET`, and `RELEASE_COOKIE`, and may additionally
|
||||||
intentionally has no invented CPU/RAM limits or HPA thresholds; measure this
|
contain both GitHub OAuth variables described above. The chart intentionally
|
||||||
application in the target environment before setting them.
|
has no invented CPU/RAM limits or HPA thresholds; measure this application in
|
||||||
|
the target environment before setting them.
|
||||||
|
|
||||||
## Local Codex category review
|
## Local Codex category review
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ x-app-environment: &app-environment
|
||||||
CODEX_SESSION_ID: ${CODEX_SESSION_ID:-not-configured}
|
CODEX_SESSION_ID: ${CODEX_SESSION_ID:-not-configured}
|
||||||
RATE_LIMIT_POLICIES_JSON: ${RATE_LIMIT_POLICIES_JSON:-{}}
|
RATE_LIMIT_POLICIES_JSON: ${RATE_LIMIT_POLICIES_JSON:-{}}
|
||||||
MAP_TILE_URL: ${MAP_TILE_URL:-https://tile.openstreetmap.org/{z}/{x}/{y}.png}
|
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:
|
services:
|
||||||
proxy:
|
proxy:
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,32 @@ config :who_need_help,
|
||||||
json -> Jason.decode!(json)
|
json -> Jason.decode!(json)
|
||||||
end)
|
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
|
if dns_query = System.get_env("DNS_CLUSTER_QUERY") do
|
||||||
config :who_need_help, :dns_cluster_query, dns_query
|
config :who_need_help, :dns_cluster_query, dns_query
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ config :who_need_help, WhoNeedHelpWeb.Endpoint,
|
||||||
|
|
||||||
# In test we don't send emails
|
# In test we don't send emails
|
||||||
config :who_need_help, WhoNeedHelp.Mailer, adapter: Swoosh.Adapters.Test
|
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
|
# Disable swoosh api client as it is only required for production adapters
|
||||||
config :swoosh, :api_client, false
|
config :swoosh, :api_client, false
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,9 @@ app:
|
||||||
smtpPort: "1025"
|
smtpPort: "1025"
|
||||||
|
|
||||||
# Required. The Secret must contain DATABASE_URL, SECRET_KEY_BASE,
|
# 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: ""
|
existingSecret: ""
|
||||||
|
|
||||||
ingress:
|
ingress:
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,10 @@ are separate operational work and are not represented as complete.
|
||||||
|
|
||||||
- `Accounts`: users, authentication, social identities, privacy preferences,
|
- `Accounts`: users, authentication, social identities, privacy preferences,
|
||||||
blocks, and roles.
|
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
|
- `Catalog`: category tree, proposals, votes, moderation decisions, and
|
||||||
translated labels.
|
translated labels.
|
||||||
- `Help`: requests, assignments, state transitions, handover codes, and
|
- `Help`: requests, assignments, state transitions, handover codes, and
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ rebuilt deterministically.
|
||||||
| Phoenix LiveView | 1.2.7 |
|
| Phoenix LiveView | 1.2.7 |
|
||||||
| Ecto / Ecto SQL | 3.14.1 / 3.14.0 |
|
| Ecto / Ecto SQL | 3.14.1 / 3.14.0 |
|
||||||
| Oban | 2.23.0 |
|
| Oban | 2.23.0 |
|
||||||
|
| Assent | 0.3.1 |
|
||||||
| Node.js LTS | 24.18.0 |
|
| Node.js LTS | 24.18.0 |
|
||||||
| npm build tool | 12.0.1 |
|
| npm build tool | 12.0.1 |
|
||||||
| Tailwind CSS binary | 4.3.3 |
|
| Tailwind CSS binary | 4.3.3 |
|
||||||
|
|
|
||||||
|
|
@ -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
|
matched chat, plus a user-started native location foreground service with a
|
||||||
persistent Stop notification; reproducible debug APK build.
|
persistent Stop notification; reproducible debug APK build.
|
||||||
- Admin-only local Codex moderation batch for category proposals.
|
- 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
|
## Explicitly outside the MVP
|
||||||
|
|
||||||
|
|
@ -75,8 +77,8 @@ helper per request. Every state transition is authorized and recorded.
|
||||||
- Multiple simultaneous helpers on one request.
|
- Multiple simultaneous helpers on one request.
|
||||||
- Production-signed Android release, store publication, and native iOS
|
- Production-signed Android release, store publication, and native iOS
|
||||||
application.
|
application.
|
||||||
- OAuth verification and automated social-network identity checks. The MVP
|
- OAuth verification for providers other than GitHub and automated
|
||||||
supports manually attached public links and marks them unverified.
|
social-network identity scoring.
|
||||||
- Automatic punitive abuse enforcement and unapproved production thresholds.
|
- Automatic punitive abuse enforcement and unapproved production thresholds.
|
||||||
- Claims that identity, safety, or fraud prevention is perfect.
|
- 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
|
boolean fields through its stored schema without changing the request form or
|
||||||
core state machine. Moderated proposals can extend the tree further.
|
core state machine. Moderated proposals can extend the tree further.
|
||||||
|
|
||||||
Social links are user-provided references and are visibly marked unverified.
|
User-provided social links are visibly marked unverified. A separately
|
||||||
The data model reserves `verified_at` for a future OAuth flow, but the current
|
configured GitHub OAuth flow can set `verified_at` after provider authorization;
|
||||||
MVP does not set it.
|
one provider account cannot be linked to two local accounts. The provider
|
||||||
|
access token is discarded rather than stored.
|
||||||
|
|
||||||
## Privacy settings
|
## Privacy settings
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,8 +54,16 @@ only for completed matched requests.
|
||||||
## Identity and social links
|
## Identity and social links
|
||||||
|
|
||||||
Email ownership can be confirmed through a magic link. Manually attached
|
Email ownership can be confirmed through a magic link. Manually attached
|
||||||
Instagram, Facebook, Telegram, Google, or other URLs are labelled “unverified.”
|
GitHub, Instagram, Facebook, Telegram, Google, or other URLs are labelled
|
||||||
OAuth verification is not implemented in the current MVP.
|
“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
|
The MVP uses 18+ self-attestation. It does not imply government identity
|
||||||
verification.
|
verification.
|
||||||
|
|
|
||||||
|
|
@ -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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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
|
## 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.
|
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
|
- The generated Activity migration was rolled back by exactly one step and
|
||||||
migrated forward again against `who_need_help_test`; both directions passed.
|
migrated forward again against `who_need_help_test`; both directions passed.
|
||||||
- The Activity-report migration was also rolled back and migrated forward. The
|
- 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
|
After the Activity rollout, headed Chrome rendered the updated public
|
||||||
navigation, followed the Activity link to the authenticated route, and received
|
navigation, followed the Activity link to the authenticated route, and received
|
||||||
the expected login redirect and flash with zero console errors or warnings.
|
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
|
## 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.
|
chat, moderation, or leaderboard result sets require it.
|
||||||
- Publish jurisdiction-specific emergency contacts, privacy, retention,
|
- Publish jurisdiction-specific emergency contacts, privacy, retention,
|
||||||
prohibited-items, and voluntary-payment guidance after legal review.
|
prohibited-items, and voluntary-payment guidance after legal review.
|
||||||
- Add OAuth identity verification only if the product chooses to make verified
|
- Create and configure a GitHub OAuth App, then exercise the real external
|
||||||
social accounts a trust signal.
|
provider redirect/callback in a headed browser. Until then staging keeps the
|
||||||
- Extend scoped reporting/moderation evidence to Activity entities without
|
provider disabled and manual links remain unverified.
|
||||||
exposing unrelated group conversations.
|
|
||||||
|
|
|
||||||
|
|
@ -204,6 +204,37 @@ defmodule WhoNeedHelp.Accounts do
|
||||||
|> Repo.insert()
|
|> Repo.insert()
|
||||||
end
|
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
|
def delete_social_identity(%User{id: user_id}, identity_id) do
|
||||||
case Repo.get_by(SocialIdentity, id: identity_id, user_id: user_id) do
|
case Repo.get_by(SocialIdentity, id: identity_id, user_id: user_id) do
|
||||||
nil -> {:error, :not_found}
|
nil -> {:error, :not_found}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@ defmodule WhoNeedHelp.Accounts.SocialIdentity do
|
||||||
@foreign_key_type :binary_id
|
@foreign_key_type :binary_id
|
||||||
|
|
||||||
schema "social_identities" do
|
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 :provider_uid, :string
|
||||||
field :profile_url, :string
|
field :profile_url, :string
|
||||||
|
|
@ -27,4 +28,31 @@ defmodule WhoNeedHelp.Accounts.SocialIdentity do
|
||||||
|> validate_length(:handle, max: 100)
|
|> validate_length(:handle, max: 100)
|
||||||
|> unique_constraint([:provider, :provider_uid])
|
|> unique_constraint([:provider, :provider_uid])
|
||||||
end
|
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
|
end
|
||||||
|
|
|
||||||
43
lib/who_need_help/social_oauth.ex
Normal file
43
lib/who_need_help/social_oauth.ex
Normal 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
|
||||||
59
lib/who_need_help/social_oauth/assent_adapter.ex
Normal file
59
lib/who_need_help/social_oauth/assent_adapter.ex
Normal 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
|
||||||
30
lib/who_need_help/social_oauth/github_strategy.ex
Normal file
30
lib/who_need_help/social_oauth/github_strategy.ex
Normal 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
|
||||||
108
lib/who_need_help_web/controllers/social_oauth_controller.ex
Normal file
108
lib/who_need_help_web/controllers/social_oauth_controller.ex
Normal 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
|
||||||
|
|
@ -265,6 +265,29 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
||||||
<dt class="text-xs text-base-content/50">Organizer</dt>
|
<dt class="text-xs text-base-content/50">Organizer</dt>
|
||||||
<dd class="font-semibold">{@activity.creator.display_name || "Community member"}</dd>
|
<dd class="font-semibold">{@activity.creator.display_name || "Community member"}</dd>
|
||||||
</div>
|
</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>
|
</dl>
|
||||||
|
|
||||||
<div :if={map_size(@activity.structured_data) > 0} class="mt-5">
|
<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"
|
class="rounded-xl bg-base-200 p-3"
|
||||||
>
|
>
|
||||||
<div class="font-semibold">{participant.user.display_name || "Community member"}</div>
|
<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">
|
<div class="mt-2 flex gap-2">
|
||||||
<button
|
<button
|
||||||
phx-click="approve"
|
phx-click="approve"
|
||||||
|
|
@ -417,6 +460,26 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
||||||
<span :if={participant.role == :organizer} class="badge badge-xs ml-1">
|
<span :if={participant.role == :organizer} class="badge badge-xs ml-1">
|
||||||
organizer
|
organizer
|
||||||
</span>
|
</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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ defmodule WhoNeedHelpWeb.ProfileLive do
|
||||||
use WhoNeedHelpWeb, :live_view
|
use WhoNeedHelpWeb, :live_view
|
||||||
|
|
||||||
alias WhoNeedHelp.Accounts
|
alias WhoNeedHelp.Accounts
|
||||||
|
alias WhoNeedHelp.SocialOAuth
|
||||||
alias WhoNeedHelp.Trust
|
alias WhoNeedHelp.Trust
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -14,6 +15,7 @@ defmodule WhoNeedHelpWeb.ProfileLive do
|
||||||
|> assign(:reputation, Trust.reputation(user.id))
|
|> assign(:reputation, Trust.reputation(user.id))
|
||||||
|> assign(:reviews, Trust.visible_reviews(user.id))
|
|> assign(:reviews, Trust.visible_reviews(user.id))
|
||||||
|> assign(:blocks, Trust.list_blocks(socket.assigns.current_scope))
|
|> 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(:form, to_form(Accounts.change_user_profile(user)))
|
||||||
|> assign_social_identities(user)}
|
|> assign_social_identities(user)}
|
||||||
end
|
end
|
||||||
|
|
@ -169,12 +171,27 @@ defmodule WhoNeedHelpWeb.ProfileLive do
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-bold">Social links</h2>
|
<h2 class="text-xl font-bold">Social links</h2>
|
||||||
<p class="mt-1 text-sm text-base-content/55">
|
<p class="mt-1 text-sm text-base-content/55">
|
||||||
Manually added links are public and marked unverified. OAuth verification is not
|
Manually added links are public and marked unverified. A verified badge is only
|
||||||
enabled in this MVP.
|
granted after completing a provider authorization flow.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<.link
|
||||||
|
:if={@github_oauth_enabled}
|
||||||
|
href={~p"/auth/social/github"}
|
||||||
|
class="btn btn-primary btn-sm"
|
||||||
|
>
|
||||||
|
Verify GitHub
|
||||||
|
</.link>
|
||||||
</div>
|
</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="mt-5 grid gap-4 md:grid-cols-[1fr_.9fr]">
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<p :if={@social_identities == []} class="text-sm text-base-content/55">
|
<p :if={@social_identities == []} class="text-sm text-base-content/55">
|
||||||
|
|
@ -221,6 +238,7 @@ defmodule WhoNeedHelpWeb.ProfileLive do
|
||||||
type="select"
|
type="select"
|
||||||
label="Network"
|
label="Network"
|
||||||
options={[
|
options={[
|
||||||
|
{"GitHub", "github"},
|
||||||
{"Instagram", "instagram"},
|
{"Instagram", "instagram"},
|
||||||
{"Facebook", "facebook"},
|
{"Facebook", "facebook"},
|
||||||
{"Telegram", "telegram"},
|
{"Telegram", "telegram"},
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,8 @@ defmodule WhoNeedHelpWeb.Router do
|
||||||
get "/users/settings", UserSettingsController, :edit
|
get "/users/settings", UserSettingsController, :edit
|
||||||
put "/users/settings", UserSettingsController, :update
|
put "/users/settings", UserSettingsController, :update
|
||||||
get "/users/settings/confirm-email/:token", UserSettingsController, :confirm_email
|
get "/users/settings/confirm-email/:token", UserSettingsController, :confirm_email
|
||||||
|
get "/auth/social/:provider", SocialOAuthController, :request
|
||||||
|
get "/auth/social/:provider/callback", SocialOAuthController, :callback
|
||||||
end
|
end
|
||||||
|
|
||||||
scope "/", WhoNeedHelpWeb do
|
scope "/", WhoNeedHelpWeb do
|
||||||
|
|
|
||||||
1
mix.exs
1
mix.exs
|
|
@ -71,6 +71,7 @@ defmodule WhoNeedHelp.MixProject do
|
||||||
{:swoosh, "~> 1.16"},
|
{:swoosh, "~> 1.16"},
|
||||||
{:gen_smtp, "~> 1.3"},
|
{:gen_smtp, "~> 1.3"},
|
||||||
{:req, "~> 0.5"},
|
{:req, "~> 0.5"},
|
||||||
|
{:assent, "~> 0.3.1"},
|
||||||
{:telemetry_metrics, "~> 1.0"},
|
{:telemetry_metrics, "~> 1.0"},
|
||||||
{:telemetry_poller, "~> 1.0"},
|
{:telemetry_poller, "~> 1.0"},
|
||||||
{:gettext, "~> 1.0"},
|
{:gettext, "~> 1.0"},
|
||||||
|
|
|
||||||
1
mix.lock
1
mix.lock
|
|
@ -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"},
|
"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"},
|
"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"},
|
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
|
||||||
|
|
|
||||||
40
test/support/social_oauth_fake.ex
Normal file
40
test/support/social_oauth_fake.ex
Normal 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
|
||||||
43
test/who_need_help/social_oauth_test.exs
Normal file
43
test/who_need_help/social_oauth_test.exs
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -4,7 +4,7 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
||||||
import Phoenix.LiveViewTest
|
import Phoenix.LiveViewTest
|
||||||
import WhoNeedHelp.AccountsFixtures
|
import WhoNeedHelp.AccountsFixtures
|
||||||
|
|
||||||
alias WhoNeedHelp.{Activities, Catalog, Help, Messaging, Trust}
|
alias WhoNeedHelp.{Accounts, Activities, Catalog, Help, Messaging, Trust}
|
||||||
alias WhoNeedHelp.Repo
|
alias WhoNeedHelp.Repo
|
||||||
|
|
||||||
setup :register_and_log_in_user
|
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", %{
|
test "organizer approves an activity participant and group chat updates live", %{
|
||||||
conn: organizer_conn,
|
conn: organizer_conn,
|
||||||
scope: organizer_scope
|
scope: organizer_scope,
|
||||||
|
user: organizer
|
||||||
} do
|
} do
|
||||||
Catalog.seed_defaults()
|
Catalog.seed_defaults()
|
||||||
participant = user_fixture(display_name: "Activity participant")
|
participant = user_fixture(display_name: "Activity participant")
|
||||||
participant_conn = build_conn() |> log_in_user(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 =
|
coffee =
|
||||||
Catalog.list_categories(:activity)
|
Catalog.list_categories(:activity)
|
||||||
|> Enum.find(&(&1.slug == "coffee-meetup"))
|
|> Enum.find(&(&1.slug == "coffee-meetup"))
|
||||||
|
|
@ -123,6 +140,7 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
||||||
assert html =~ "Safety controls"
|
assert html =~ "Safety controls"
|
||||||
assert html =~ "Block organizer"
|
assert html =~ "Block organizer"
|
||||||
assert html =~ "Send report"
|
assert html =~ "Send report"
|
||||||
|
assert html =~ "telegram · unverified"
|
||||||
refute html =~ "Approved group chat"
|
refute html =~ "Approved group chat"
|
||||||
|
|
||||||
participant_view
|
participant_view
|
||||||
|
|
@ -130,13 +148,17 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
assert render(participant_view) =~ "Approval pending"
|
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
|
organizer_view
|
||||||
|> element("button[phx-click='approve']")
|
|> element("button[phx-click='approve']")
|
||||||
|> render_click()
|
|> 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 =
|
html =
|
||||||
participant_view
|
participant_view
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user