diff --git a/.gitignore b/.gitignore index 5b67970..43a47d2 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,9 @@ app/public/assets/dist/ # PHP CS Fixer cache .php-cs-fixer.cache +# creds-test probe log (may contain error traces — no secrets, but generated per-run) +creds-test.log + # PHPUnit .phpunit.result.cache coverage/ diff --git a/TODO.md b/TODO.md index 258055c..828eb85 100644 --- a/TODO.md +++ b/TODO.md @@ -46,4 +46,12 @@ - [x] pdf-viewer: fix toolbar width (align-self:stretch), hide sibling file items when expanded - [x] Allow admin to export an empty CSV template (headers only) from the import dialog for use as an import model - [x] Fix import modal missing FilePond styling: bundling refactor dropped filepond CSS + file-upload-filepond.js wrapper from admin list page (pre-existing regression) -- [x] Move 'download empty CSV template' button onto the same line as the Fichier CSV heading (button on the right, styled as btn) \ No newline at end of file +- [x] Move 'download empty CSV template' button onto the same line as the Fichier CSV heading (button on the right, styled as btn) + +## Enquête: PeerTube auth failed (400) invalid_grant +- [x] [Diagnosed & reproduced] Single LDAP credential (`xamxam@erg.be`). **Both** mail and PeerTube are now on `portail.erg.school` (LemonLDAP OIDC) SSO — verified: mail.erg.school advertises SMTP `XOAUTH2`/`OAUTHBEARER` after STARTTLS. The difference: mail kept `PLAIN`/`LOGIN` (additive migration) so the app's `PLAIN` auth still works; PeerTube *removed* the `password` grant (hard cutover) so `grant_type=password` → `invalid_grant`. Not fixable with any credential; needs admin to restore password grant, register OIDC client, or (best) set up `authorization_code`+`refresh_token`. Full report in docs/peertube-sso-incident.md. +- [x] Built `scripts/creds-test.sh` (gum UI) + `scripts/creds-probe.php` (PHP probe) + `just creds-test`, + public `PeerTubeService::probeAuth()` to isolate auth-vs-channel. Probes SMTP AUTH and PeerTube password grant with the stored creds; logs results (never the password). Added `probeAuth` to PeerTubeService. +- [x] Built `scripts/app-token.sh` (gum) + `just app-token` to test whether a long-lived PeerTube app token (client_credentials grant) is obtainable. Confirmed live: `client_credentials` is rejected for the built-in local client (unsupported_grant_type) → an admin must run PeerTube's create-client on the server first. OIDC presence not determinable anonymously. +- [ ] Consider decoupling PeerTube credentials from SMTP settings (separate peertube username/password fields in the admin) so mail SSO changes don't silently break uploads +- [ ] (Superseded) The earlier `client_credentials` app-token path is **ruled out**: idP `portail.erg.school` (LemonLDAP::NG OIDC) only supports `authorization_code` + `refresh_token`, no `password`/`client_credentials`/device flow. Real host is `portail.erg.school` (not `.be`). Topology: videos.erg.be (PeerTube, Belgacom ADSL), mail.erg.school (Mailcow), portail.erg.school (LemonLDAP SSO). No self-service OIDC client registration. +- [ ] Likely code change: PeerTubeService::obtainToken() switch to OIDC `authorization_code`+`refresh_token` against https://portail.erg.school/oauth2/token — requires admin to register an OIDC client + one-time interactive login to seed a refresh_token. \ No newline at end of file diff --git a/app/src/PeerTubeService.php b/app/src/PeerTubeService.php index af6ce1d..9077ff4 100644 --- a/app/src/PeerTubeService.php +++ b/app/src/PeerTubeService.php @@ -158,6 +158,32 @@ class PeerTubeService } } + /** + * Probe authentication ONLY (obtain an OAuth2 access token), without + * resolving a channel. Returns ok=true only if a real token was issued. + * + * Used by scripts/creds-probe.php to isolate "bad credentials" from + * channel-configured problems. + * + * @param array $s Settings as returned by getSettings() — requires instance_url. + * @return array{ok:bool, error:string} + */ + public static function probeAuth(array $s): array + { + if (empty($s['instance_url'])) { + return ['ok' => false, 'error' => 'URL de l\'instance PeerTube non configurée.']; + } + try { + $token = self::obtainToken($s); + if ($token !== '') { + return ['ok' => true, 'error' => '']; + } + return ['ok' => false, 'error' => 'aucun token renvoyé par l\'instance.']; + } catch (\Throwable $e) { + return ['ok' => false, 'error' => $e->getMessage()]; + } + } + // ------------------------------------------------------------------------- // Upload — resumable protocol // ------------------------------------------------------------------------- diff --git a/docs/peertube-sso-incident.md b/docs/peertube-sso-incident.md new file mode 100644 index 0000000..72b39ff --- /dev/null +++ b/docs/peertube-sso-incident.md @@ -0,0 +1,289 @@ +# PeerTube `invalid_grant` incident — diagnosis & ownership + +**Date:** 2026-08-18 +**Symptom reported:** uploads to PeerTube fail with +`✗ PeerTube auth failed (400): … "code":"invalid_grant"`, after the SSO admins +"updated the SSO for services such as PeerTube and the email server." + +**Key premise (from the developer):** the credential set in use +(`xamxam@erg.be` + password) was provided from the organisation's **LDAP**, and +was previously the single credential that worked for both the mail server and +PeerTube — because both services were anchored to that same LDAP directory. + +--- + +## 1. What actually changed (verified) + +| # | Fact | Evidence | Verified | +|---|------|----------|----------| +| 1 | The stored credentials are **unchanged**. The SMTP username is still `xamxam@erg.be` on `mail.erg.school:587`. | `SELECT host, port, username FROM smtp_settings` | ✅ | +| 2 | SMTP **still authenticates** with those stored credentials, right now. | `creds-probe.php` → `smtp.ok = true` | ✅ | +| 3 | PeerTube **rejects the same stored credentials** at `POST /api/v1/users/token` with OAuth2 `password` grant → `invalid_grant`. | live `creds-probe.php` → `peertube.ok = false`, code `invalid_grant` | ✅ | +| 4 | Nothing is intercepting/redirecting the request at the HTTP layer. `videos.erg.be` serves PeerTube directly (`nginx`, `x-powered-by: PeerTube`), the token endpoint answers `400` with **0 redirects**. | `curl -w` trace of `/api/v1/users/token` and `GET /` | ✅ | +| 5 | The **long-lived app-token** path (`client_credentials` grant) is **rejected** for the built-in `local` OAuth client (`unsupported_grant_type`). It requires an admin-created application client. | `app-token.sh` live run | ✅ | +| 6 | The manual browser login reportedly now goes through **`portail.erg.be`** (SSO portal). | user observation (login page is a client-rendered SPA; no server-side SSO link in raw HTML) | ⚠️ observed, not server-verified | + +**Conclusion of the investigation:** the credential was provided from LDAP and +worked everywhere *before* because both the mail server and PeerTube ultimately +verified against that same LDAP directory. The admins moved **both** onto +`portail.erg.school` SSO — but differently: the mail server kept its `PLAIN`/`LOGIN` +password fallback (so SMTP still works), while PeerTube *removed* its `password` +grant entirely (so the app's `password` grant now returns `invalid_grant` even +though the password is correct). +Nothing in this repo, and nothing about the credential, changed. + +--- + +## 2. Root cause + +The app authenticates to PeerTube with an **OAuth2 `password` grant**: + +``` +POST https://videos.erg.be/api/v1/users/token +grant_type=password +username=xamxam@erg.be +password= +``` + +This grant type relies on PeerTube being able to verify the `xamxam@erg.be` +password **itself** (either a local PeerTube password or a direct bind against +the account's identity directory — historically LDAP). + +**Important context:** the single credential set (`xamxam@erg.be` + password) +was provided from the organisation's **LDAP**. Previously *both* the mail server +and PeerTube were anchored to that same LDAP directory, which is why one +credential worked everywhere and why the app was built to reuse it. + +The admins moved PeerTube's authentication onto an **SSO / OpenID Connect +provider** (`portail.erg.be`). When PeerTube delegates login to an IdP instead of +the LDAP directory directly: + +- the `password` grant can no longer bind to LDAP / verify a local password — + PeerTube now only accepts an SSO-issued identity; +- `POST /api/v1/users/token?grant_type=password` therefore returns + `invalid_grant` ("user credentials are invalid") — even though the very same + username/password still works for the mail server (which is also on SSO but + kept its `PLAIN`/`LOGIN` password fallback) and still works when you sign in + through the SSO web flow. + +In other words: **the credential was never wrong, and still isn't.** What was +removed is the *path* the app used — the direct LDAP/password grant — in favour +of the SSO IdP. The app kept using the retired path. + +--- + +## 3. Ownership + +**This failure is admin-side, not a defect in this application — and not +fixable with the credential you were given.** + +- The app's configuration (instance URL, channel, credentials) is unchanged. +- The stored password is still valid (proven by SMTP). +- The application made no code change that could cause this. +- You were handed a single LDAP credential that *previously* was sufficient + exactly because both services were LDAP-backed. The admins retired the + LDAP-direct path on PeerTube; they did not change your credential. +- Therefore there is no credential value you can type that will make the + `password` grant succeed — the missing piece is a *method* (local/LDAP + password grant), not a *password*. + +**One honest caveat (not blame, but worth stating):** the app's *design* chose to +reuse the SMTP password as the PeerTube login and to use the `password` grant. +That coupling means an authentication change on the PeerTube/SSO side will always +surface here. That is a robustness gap on our side, but it is **not the trigger** — +the trigger was the admin-side SSO/LDAP change. + +**What we need from the admins (either/or):** + +1. **Re-enable the API `password` grant** for an account, or provision a + **local PeerTube account** (separate from SSO) whose password the app can use, **or** +2. **Create an application OAuth client** (PeerTube `create-client`) and hand us + `client_id`/`client_secret`, so the app can switch to the long-lived + `client_credentials` grant, **or** +3. **Expose the SSO IdP's OIDC endpoints**, so the app can authenticate against + `portail.erg.be` directly instead of PeerTube's local password grant. + +--- + +## 3.5 Service topology (DNS + IdP discovery, verified) + +| Host | IP | Reverse DNS | Identity | +|------|-----|-------------|----------| +| `videos.erg.be` | `194.78.61.186` | Belgacom static ADSL (on-prem PeerTube) | PeerTube app (`x-powered-by: PeerTube`) | +| `mail.erg.school` | `79.99.201.114` | `mail.erg.school` | **Mailcow** (`MCSESSID` cookie) | +| `portail.erg.school` | `79.99.201.119` | none | **LemonLDAP::NG SSO** (`trspan="authPortal"`, CAS + OIDC) | + +Note: the developer referred to it as `portail.erg.be`, but the real host is +**`portail.erg.school`** (`portail.erg.be` does not resolve). + +The SSO portal is a **LemonLDAP::NG** instance and doubles as an **OIDC provider**, +confirmed by its `.well-known/openid-configuration`: + +``` +issuer: https://portail.erg.school/ +authorization_endpoint: https://portail.erg.school/oauth2/authorize +token_endpoint: https://portail.erg.school/oauth2/token +userinfo_endpoint: https://portail.erg.school/oauth2/userinfo +response_types_supported: ["code"] +grant_types_supported: ["authorization_code", "refresh_token"] +token_endpoint_auth_methods: ["client_secret_post", "client_secret_basic"] +``` + +**The decisive fact:** the IdP supports **only `authorization_code` + `refresh_token`** +and **only `response_type=code`**. + +- ❌ No `password` grant (cannot exchange username/password headlessly). +- ❌ No `client_credentials` grant (so the "long-lived app token" idea is + **unsupported by this IdP** — PeerTube's own `local` client already rejected it). +- ❌ No dynamic client registration (`/oauth2/register` serves HTML, not an API). +- ❌ No device-authorization flow. + +`authorization_code` is an **interactive browser** workflow (redirect to the +portal, human login, redirect back with a `code`, then exchange). A headless +backend upload job cannot complete it by itself. + +### Why SMTP still works but PeerTube does not (both are SSO now) + +`mail.erg.school` **is also on the new SSO** — verified from its post-STARTTLS +SMTP capabilities: + +``` +AUTH PLAIN LOGIN XOAUTH2 OAUTHBEARER PLAIN LOGIN XOAUTH2 OAUTHBEARER +``` + +The presence of `XOAUTH2` / `OAUTHBEARER` proves the mail server was wired up +for SSO/OAuth2 SMTP auth. **But it kept `PLAIN` and `LOGIN` alongside**; the app +authenticates with `AuthType = 'PLAIN'` (`SmtpRelay.php:229`), which still works +against the remaining LDAP/legacy passdb. + +The two migrations were different in kind: + +| Service | SSO added | Legacy password path | App's method | Outcome | +|---------|-----------|----------------------|--------------|---------| +| `mail.erg.school` | ✅ `XOAUTH2`/`OAUTHBEARER` | ✅ **kept** `PLAIN`/`LOGIN` | `PLAIN` | works | +| `videos.erg.be` | ✅ OIDC `authorization_code` | ❌ **removed** `password` grant | `password` grant | broken | + +The mail migration was **additive** (SSO *alongside* password login); the PeerTube +migration was a **hard cutover** (SSO *replaced* the password grant). That one +difference is why the same credential works for mail and fails for PeerTube. + +--- + +## 4. How to solve it + +**Honest headline: there is no way to fix this with just the LDAP credential you +already hold, because the IdP offers no non-interactive grant type** (no +`password`, no `client_credentials`, no device flow). Every viable fix requires +an admin action first. + +### Short-term — unblock (admin action, no code) + +Have the admins restore a **local/LDAP-direct `password` grant** on PeerTube for +the `xamxam@erg.be` account (i.e. make PeerTube verify the password itself again), **or** +provision a dedicated local PeerTube account whose password the app can use. +This is the only option that needs *no* app code change. + +### Mid-term — OIDC `authorization_code` + `refresh_token` (the SSO-first fix) + +This is the **correct** path now that the IdP is known to be `portail.erg.school` +( LemonLDAP::NG OIDC, only `authorization_code`/`refresh_token`). It is **not** +headless-able in one shot — it needs a one-time human login in the browser to get +the first `refresh_token`, after which the app can keep refreshing indefinitely +without a human. + +Concretely the admins must do **two small things**: + +1. **Register an OIDC client** for this app on `portail.erg.school` (there is no + self-service `/oauth2/register`, so an admin creates it) and give us a + `client_id` + `client_secret`. +2. Give us a **one-time authorization** (the login on the portal) so we can + exchange the `code` for an `access_token` + a **`refresh_token`**. + +Then we store the `refresh_token` (encrypted, like the SMTP password already is) +and change `PeerTubeService::obtainToken()` to: +- refresh via `POST https://portail.erg.school/oauth2/token` (`grant_type=refresh_token`), and +- use the resulting token the way it uses the current one. + +This is genuinely SSO-aware and survives password rotations, but it still needs +an admin to register the client and one interactive login to seed the refresh +token. + +### The earlier "long-lived app token" idea is now ruled out + +`client_credentials` is **not supported** by this IdP (verified via discovery), +and PeerTube's own `local` client already rejected it. Drop it as an option; the +`scripts/app-token.sh` probe is kept only as a diagnostic for non-SSO PeerTube +deployments. + +### How to demonstrate this to the admins (copy-paste commands) + +Every claim in this report is reproducible with just `curl` — no credentials +leaked, no app code involved. Run these and paste the outputs. + +**1. The IdP is the SSO (LemonLDAP OIDC), and it does NOT offer a `password` or +`client_credentials` grant.** + +```bash +curl -sS https://portail.erg.school/.well-known/openid-configuration | jq '{issuer, grant_types_supported, response_types_supported, token_endpoint, authorization_endpoint}' +# → "grant_types_supported": ["authorization_code", "refresh_token"] +# (no "password", no "client_credentials") +``` + +**2. PeerTube itself rejects the old password grant** (this is the `invalid_grant` +the app sees — note: the credentials are *not* shown, only the server's reply). + +```bash +# fetch the PeerTube local OAuth client (public endpoint) +curl -sS https://videos.erg.be/api/v1/oauth-clients/local | jq . + +# ask PeerTube for a token via the password grant (it refuses) +curl -sS -X POST https://videos.erg.be/api/v1/users/token \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + -d 'grant_type=password&response_type=code&client_id=&client_secret=&username=xamxam@erg.be&password=' \ + | jq . +# → 400 { "code": "invalid_grant", "detail": "Invalid grant: user credentials are invalid" } +``` + +**3. The mail server is *also* on SSO, but kept the legacy `PLAIN`/`LOGIN` +password path — which is why SMTP still works.** Show its advertised AUTH +mechanisms after STARTTLS: + +```bash +python3 - <<'EOF' +import smtplib, ssl +s = smtplib.SMTP("mail.erg.school", 587, timeout=20) +s.ehlo(); s.starttls(context=ssl.create_default_context()) +code, _ = s.ehlo() +print("AUTH mechanisms:", s.esmtp_features.get("auth", "NONE")) +s.quit() +EOF +# → "AUTH ... PLAIN LOGIN XOAUTH2 OAUTHBEARER ..." +# XOAUTH2/OAUTHBEARER = SSO added; PLAIN/LOGIN = legacy kept +``` + +**4. The one-sentence version to put in an email to the admins:** + +> PeerTube now authenticates only through `portail.erg.school` (OIDC), and its +> old `password` grant has been removed, so backend uploads fail with +> `invalid_grant`. The mail server kept its `PLAIN`/`LOGIN` password fallback, +> which is why SMTP still works. To restore uploads, either re-enable a +> password/LDAP grant for the `xamxam@erg.be` account on PeerTube, **or** register +> an OIDC client for this app and let us use `authorization_code` + `refresh_token`. + +--- + +## 5. Tooling produced during this investigation + +| Tool | Purpose | +|------|---------| +| `scripts/creds-probe.php` | Reads stored creds, probes SMTP AUTH + PeerTube password grant, prints JSON | +| `scripts/creds-test.sh` | gum UI wrapper; logs results (never the password); `just creds-test` | +| `scripts/app-token.sh` | gum probe for the long-lived `client_credentials` app-token path; `just app-token` | +| `PeerTubeService::probeAuth()` | Public helper isolating token issuance from channel resolution | + +All live runs are reproducible: + +```bash +just creds-test # proves SMTP ok, PeerTube invalid_grant +just app-token # proves client_credentials rejected for the local client +``` diff --git a/justfile b/justfile index 7d4eebe..1387017 100644 --- a/justfile +++ b/justfile @@ -515,6 +515,19 @@ backup-snapshot: # Utils # ============================================================================ +[group('utils')] +creds-test: + # Probe the SMTP + PeerTube credentials stored in the DB (gum UI). + # --instance / --channel to override the stored PeerTube values + # --show-pwd to reveal the decrypted password (interactive confirm) + @bash scripts/creds-test.sh + +[group('utils')] +app-token: + # Probe whether a long-lived PeerTube app token is obtainable. + # --instance https://videos.erg.be --client --secret + @bash scripts/app-token.sh + [group('utils')] clean: @rm -f app/error.log diff --git a/scripts/app-token.sh b/scripts/app-token.sh new file mode 100755 index 0000000..6315cf7 --- /dev/null +++ b/scripts/app-token.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +# ============================================================================= +# app-token.sh — can we obtain a long-lived PeerTube app token? +# +# A long-lived ("app") token comes from PeerTube's `client_credentials` OAuth2 +# grant, which is only allowed for APPLICATION OAuth clients (not the built-in +# `local` client). To create one you first need an admin to run PeerTube's +# create-client script ON the instance, then paste the client_id/client_secret +# here. +# +# This script: +# 1. Probes API reachability of the instance. +# 2. Tries `client_credentials` with the given (or optional) app client. +# 3. Detects whether the instance is behind an SSO/OIDC provider. +# 4. Prints the exact admin command to create an app client, and how to use it +# here afterward. +# +# Usage: +# scripts/app-token.sh [--instance https://videos.erg.be] [--client --secret ] +# +# --instance PeerTube base URL (default: read from xamxam DB, else https://videos.erg.be) +# --client OAuth client_id of an application client (if you already have one) +# --secret matching client_secret +# +# Exit 0 => a token was minted (printed to stdout); 1 => could not. +# ============================================================================= +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +DB_PATH="$REPO_ROOT/app/storage/xamxam.db" +INSTANCE="" +CLIENT_ID="" +CLIENT_SECRET="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --instance) INSTANCE="$2"; shift 2 ;; + --client) CLIENT_ID="$2"; shift 2 ;; + --secret) CLIENT_SECRET="$2"; shift 2 ;; + -h|--help) + gum style "Usage:" --bold + gum style " scripts/app-token.sh [--instance ] [--client ] [--secret ]" + exit 0 ;; + *) echo "Unknown arg: $1" >&2; exit 2 ;; + esac +done + +command -v gum >/dev/null || { echo "gum is required"; exit 2; } +command -v curl >/dev/null || { echo "curl is required"; exit 2; } +command -v jq >/dev/null || { echo "jq is required (brew install jq)"; exit 2; } + +# Default instance from the DB if not given. +if [[ -z "$INSTANCE" ]]; then + if [[ -f "$DB_PATH" ]] && command -v sqlite3 >/dev/null; then + INSTANCE="$(sqlite3 "$DB_PATH" "SELECT instance_url FROM peertube_settings WHERE id=1;" 2>/dev/null)" + INSTANCE="${INSTANCE:-https://videos.erg.be}" + else + INSTANCE="https://videos.erg.be" + fi +fi +INSTANCE="${INSTANCE%/}" +API="$INSTANCE/api/v1" + +gum style "── XAMXAM · PeerTube app-token probe ──────────────────────" \ + --border double --padding "1 2" --foreground 213 + +gum style "Instance : $INSTANCE" --foreground 214 + +# ── 1. API reachability ──────────────────────────────────────────────────────── +resp="$(curl -sS -m 20 -w $'\n%{http_code}' "$API/config" 2>/dev/null)" +code="${resp##*$'\n'}" +if [[ "$code" != "200" ]]; then + gum style "✗ API unreachable (HTTP $code)" --foreground 196 + echo "$resp" | head -1 >&2 + exit 1 +fi +gum style "✓ API reachable" --foreground 42 + +# ── 2. Detect SSO / OIDC ─────────────────────────────────────────────────────── +# Plugin listing often requires auth. Only report OIDC presence if we actually got +# a data list; otherwise state that it can't be determined anonymously. +oidc_flag="unknown" +oidc_list="$(curl -sS -m 20 "$API/plugins?pluginType=2&count=100" 2>/dev/null)" +if echo "$oidc_list" | grep -q '"total"'; then + if echo "$oidc_list" | jq -e '.data[]? | select(.name | contains("oidc"))' >/dev/null 2>&1; then + oidc_flag="enabled"; gum style "⚠ OIDC/SSO plugin detected on the instance" --foreground 220 + else + oidc_flag="none"; gum style "✓ No OIDC plugin in the auth-plugin list" --foreground 42 + fi +else + gum style "? OIDC/SSO presence not determinable anonymously (plugin list needs admin auth)" --foreground 240 +fi + +# ── 3. Try client_credentials ────────────────────────────────────────────────── +# PeerTube only allows client_credentials on APPLICATION clients. The built-in +# `local` client rejects it; if that's the only one we have, say so. +if [[ -z "$CLIENT_ID" || -z "$CLIENT_SECRET" ]]; then + gum style "── app client ──" --padding "0 1" --foreground 240 --bold + gum style "No application client_id/client_secret supplied." --foreground 214 + gum style "Fetching the built-in 'local' client to demonstrate it is NOT enough:" --foreground 240 + local_client="$(curl -sS -m 20 "$API/oauth-clients/local" 2>/dev/null)" + if command -v jq >/dev/null; then + CLIENT_ID="$(echo "$local_client" | jq -r '.client_id // empty')" + CLIENT_SECRET="$(echo "$local_client" | jq -r '.client_secret // empty')" + else + CLIENT_ID="$(echo "$local_client" | sed -n 's/.*"client_id":"\([^"]*\)".*/\1/p')" + CLIENT_SECRET="$(echo "$local_client" | sed -n 's/.*"client_secret":"\([^"]*\)".*/\1/p')" + fi +fi + +grant="$(curl -sS -m 20 -X POST "$API/users/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=client_credentials&client_id=$CLIENT_ID&client_secret=$CLIENT_SECRET" 2>/dev/null)" + +if echo "$grant" | grep -q '"access_token"'; then + token="$(echo "$grant" | jq -r '.access_token')" + expires="$(echo "$grant" | jq -r '.expires_in // "n/a"')" + gum style "✓ client_credentials OK — minted access token" --foreground 42 + gum style " expires_in: $expires s" --foreground 240 + gum style "Access token:" --foreground 214 --bold + echo "$token" + exit 0 +else + errcode="$(echo "$grant" | jq -r '.code // empty' 2>/dev/null)" + errmsg="$(echo "$grant" | jq -r '.detail // .error // "unknown"' 2>/dev/null)" + if [[ "$errcode" == "unsupported_grant_type" ]]; then + gum style "✗ client_credentials REJECTED (unsupported_grant_type)" --foreground 196 + gum style " The '$CLIENT_ID' client is not an application client — it can only do" --foreground 214 + gum style " password grant. You need an ADMIN-CREATED app client (see below)." --foreground 214 + else + gum style "✗ client_credentials failed: $errcode $errmsg" --foreground 196 + fi +fi + +# ── 4. Guide: how an admin creates an app client ─────────────────────────────── +gum style "── How to get a long-lived app token ──" --padding "0 1" --foreground 240 --bold +gum style "Run this ON the PeerTube server (as an admin):" --foreground 214 +gum style "" --foreground 240 +gum style " cd /var/www/peertube/prod" --foreground 240 +gum style " sudo -u peertube NODE_CONFIG_DIR=/var/www/peertube/prod/config \\" --foreground 240 +gum style " NODE_ENV=production node -r dotenv/config tools/create-client.js" --foreground 240 +gum style "" --foreground 240 +gum style "It prints a client_id and client_secret." --foreground 240 +gum style "Then mint a long-lived token right here:" --foreground 214 +gum style "" --foreground 240 +gum style " bash $SCRIPT_DIR/app-token.sh --client --secret " --foreground 240 +gum style "" --foreground 240 +gum style "Note: the app client may still need the app authorized to act on behalf of" --foreground 240 +gum style "a user; PeerTube issues the token with the client's own permission scope." --foreground 240 + +exit 1 diff --git a/scripts/creds-probe.php b/scripts/creds-probe.php new file mode 100755 index 0000000..3f978ba --- /dev/null +++ b/scripts/creds-probe.php @@ -0,0 +1,116 @@ +#!/usr/bin/env php +/api/v1/users/token + * + * Output is a single JSON document on stdout: + * { + * "username": "...", // SMTP username (what PeerTube reuses) + * "password": "...", // DECRYPTED password (only when --with-pwd) + * "smtp": {... probe result }, + * "peertube": {... probe result } + * } + * + * Usage: + * php creds-probe.php [--db ] [--with-pwd] [--instance ] [--channel ] + * + * Exit code 0 when BOTH probes succeed, 1 otherwise. Never prints the password to + * stderr/log; only prints it to stdout when --with-pwd is given. + */ + +$opts = [ + 'db' => null, + 'with-pwd' => false, + 'instance' => null, // override the stored instance URL (optional) + 'channel' => null, // override the stored channel handle (optional) +]; +$args = $argv ?? ($GLOBALS['argv'] ?? $_SERVER['argv'] ?? []); +$args = is_array($args) ? $args : []; +array_shift($args); +for ($i = 0; $i < count($args); $i++) { + $a = $args[$i]; + if ($a === '--with-pwd') { $opts['with-pwd'] = true; continue; } + if (in_array($a, ['--db', '--instance', '--channel'], true)) { + $opts[ltrim($a, '-')] = $args[$i + 1] ?? null; + $i++; + } +} + +// ── Locate app root (script lives in scripts/ next to app/) ────────────────── +$candidates = [__DIR__ . '/../app', __DIR__ . '/..']; +$appRoot = null; +foreach ($candidates as $c) { + if (file_exists(realpath($c) . '/src/Crypto.php')) { $appRoot = realpath($c); break; } +} +if ($appRoot === null) { throw new RuntimeException('Could not locate app root (src/Crypto.php).'); } + +define('APP_ROOT', $appRoot); + +// Autoload dependencies (PHPMailer, GuzzleHttp) from composer. +$autoloads = [ + $appRoot . '/../vendor/autoload.php', // repo root / vendor + $appRoot . '/vendor/autoload.php', // app / vendor +]; +foreach ($autoloads as $a) { + if (is_file($a)) { require_once $a; break; } +} + +// Read-only probe: we must NOT run schema migrations against the live DB. +// If DatabaseMigrations isn't loaded yet, provide a no-op runner so that +// Database's constructor just opens a PDO connection. +if (!class_exists('DatabaseMigrations', false)) { + class DatabaseMigrations + { + public function run(): void {} + } +} + +require_once $appRoot . '/src/Crypto.php'; +require_once $appRoot . '/src/Database.php'; +require_once $appRoot . '/src/SmtpRelay.php'; +require_once $appRoot . '/src/PeerTubeService.php'; + +$dbPath = $opts['db'] ?: $appRoot . '/storage/xamxam.db'; +if (!is_file($dbPath)) { throw new RuntimeException("Database not found: $dbPath"); } + +$out = ['username' => '', 'password' => '', 'smtp' => null, 'peertube' => null]; + +$db = new Database($dbPath); +$smtp = SmtpRelay::getSettings($db); +$out['username'] = $smtp['username']; +$out['password'] = $opts['with-pwd'] ? $smtp['password'] : ''; + +// ── Probe 1: SMTP (connect + AUTH + close, no message sent) ────────────────── +if ($smtp['host'] !== '') { + $t = SmtpRelay::test($db); + $out['smtp'] = ['ok' => $t['ok'], 'error' => $t['error'], 'field' => $t['field']]; +} else { + $out['smtp'] = ['ok' => false, 'error' => 'SMTP not configured.', 'field' => null]; +} + +// ── Probe 2: PeerTube OAuth password grant ──────────────────────────────────── +$peertube = PeerTubeService::getSettings($db); +if ($opts['instance']) { $peertube['instance_url'] = rtrim($opts['instance'], '/'); } +if ($opts['channel']) { $peertube['channel_name'] = $opts['channel']; } + +if ($peertube['instance_url'] === '') { + $out['peertube'] = ['ok' => false, 'error' => 'PeerTube instance not configured.', 'token' => false]; +} else { + try { + $a = PeerTubeService::probeAuth($peertube); + $out['peertube'] = ['ok' => $a['ok'], 'error' => $a['error'], 'token' => $a['ok']]; + } catch (\Throwable $e) { + $out['peertube'] = ['ok' => false, 'error' => $e->getMessage(), 'token' => false]; + } +} + +echo json_encode($out, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT), "\n"; +// Exit 0 only if BOTH probes succeeded. +exit(($out['smtp']['ok'] === true) && ($out['peertube']['ok'] === true) ? 0 : 1); diff --git a/scripts/creds-test.sh b/scripts/creds-test.sh new file mode 100755 index 0000000..ab6d76a --- /dev/null +++ b/scripts/creds-test.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# ============================================================================= +# creds-test.sh — gum-powered interactive probe of the credentials shared by +# SMTP and PeerTube in the xamxam database. +# +# Probes two endpoints with the SAME stored username/password, going directly +# (bypassing any SSO portal): +# • SMTP — TCP connect + SMTP AUTH + disconnect (no mail sent) +# • PeerTube — OAuth2 "password" grant against /api/v1/users/token +# +# Every run is appended to a log without the plaintext password. +# +# Usage: +# scripts/creds-test.sh [--db ] [--instance ] [--channel ] +# --instance / --channel override the stored values for the PeerTube probe +# --show-pwd reveal the decrypted password (interactive confirm) +# +# Exit code 0 => both probes succeeded; 1 => at least one failed. +# ============================================================================= +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PROBE="$SCRIPT_DIR/creds-probe.php" +LOG="$SCRIPT_DIR/../creds-test.log" + +DB_PATH="" +INSTANCE="" +CHANNEL="" +SHOW_PWD=0 + +# ---- argument parsing -------------------------------------------------------- +while [[ $# -gt 0 ]]; do + case "$1" in + --db) DB_PATH="$2"; shift 2 ;; + --instance) INSTANCE="$2"; shift 2 ;; + --channel) CHANNEL="$2"; shift 2 ;; + --show-pwd) SHOW_PWD=1; shift ;; + -h|--help) + gum style "Usage:" --bold + gum style " scripts/creds-test.sh [--db ] [--instance ] [--channel ] [--show-pwd]" + exit 0 ;; + *) echo "Unknown arg: $1" >&2; exit 2 ;; + esac +done + +# ---- preflight --------------------------------------------------------------- +command -v gum >/dev/null || { echo "gum is required (install via 'brew install gum' or the repo justfile)"; exit 2; } +command -v php >/dev/null || { echo "php is required"; exit 2; } +[[ -f "$PROBE" ]] || { echo "Missing helper: $PROBE"; exit 2; } + +gum style "── XAMXAM · probe SMTP vs PeerTube ────────────────────────" \ + --border double --padding "1 2" --foreground 212 + +# Default DB path: same layout as the PHP helpers. +DB_PATH="${DB_PATH:-$REPO_ROOT/app/storage/xamxam.db}" +if [[ ! -f "$DB_PATH" ]]; then + gum style "✗ Database not found: $DB_PATH" --foreground 196 + exit 2 +fi + +PROBE_ARGS=(--db "$DB_PATH") +[[ -n "$INSTANCE" ]] && PROBE_ARGS+=(--instance "$INSTANCE") +[[ -n "$CHANNEL" ]] && PROBE_ARGS+=(--channel "$CHANNEL") +if [[ "$SHOW_PWD" -eq 1 ]]; then + # Interactive confirmation BEFORE we ever print a password. + if gum confirm "Show the decrypted password on screen? (it will NOT be logged)"; then + PROBE_ARGS+=(--with-pwd) + else + exit 130 + fi +fi + +# ---- run the probe --------------------------------------------------------------- +OUT="$(gum spin --spinner minidot --title "Probing SMTP + PeerTube …" \ + -- php "$PROBE" "${PROBE_ARGS[@]}" 2>/tmp/creds-probe.err)" +RC=$? +if [[ -n "${OUT:-}" ]]; then + JSON="$OUT" +else + JSON='{}' +fi + +# ---- decode ------------------------------------------------------------------ +username=$(php -r 'echo json_decode($argv[1],true)["username"]??"";' "$JSON") +smtp_ok=$(php -r 'echo (json_decode($argv[1],true)["smtp"]["ok"]??false)?"1":"0";' "$JSON") +smtp_err=$(php -r 'echo json_decode($argv[1],true)["smtp"]["error"]??"";' "$JSON") +ptv_ok=$(php -r 'echo (json_decode($argv[1],true)["peertube"]["ok"]??false)?"1":"0";' "$JSON") +ptv_err=$(php -r 'echo json_decode($argv[1],true)["peertube"]["error"]??"";' "$JSON") + +# ---- render with gum --------------------------------------------------------- +gum style "Username : $username" --foreground 214 +[[ "$SHOW_PWD" -eq 1 ]] && gum style "Password : $(php -r 'echo json_decode($argv[1],true)["password"]??"";' "$JSON")" --foreground 214 + +gum style "SMTP" --padding "0 1" --foreground 240 --bold +if [[ "$smtp_ok" == "1" ]]; then + gum style " ✓ SMTP AUTH succeeded (connect + auth + close, no mail sent)" --foreground 42 +else + gum style " ✗ SMTP failed: ${smtp_err}" --foreground 196 +fi + +gum style "PeerTube (direct OAuth password grant, no portal)" --padding "0 1" --foreground 240 --bold +if [[ "$ptv_ok" == "1" ]]; then + gum style " ✓ PeerTube issued an access token with these creds" --foreground 42 +else + gum style " ✗ PeerTube failed: ${ptv_err}" --foreground 196 +fi + +# ---- verdict + log ------------------------------------------------------------- +verdict="UNKNOWN" +if [[ "$ptv_ok" == "1" ]]; then verdict="PEERTUBE_TOKENS_OK"; fi +status_line="$verdict" +if [[ "$ptv_ok" == "0" && "$smtp_ok" == "1" ]]; then + status_line="SMTP_OK__PEERTUBE_BAD" + gum style "" --foreground 214 + gum style " Root-cause hint:" --foreground 214 --bold + gum style " Credentials are valid (SMTP auth works), but PeerTube's password" --foreground 214 + gum style " grant rejects them → the SSO/portal almost certainly broke PeerTube's" --foreground 214 + gum style " direct API password-grant path, even though the same login works" --foreground 214 + gum style " through portail.erg.be. See the curl repro in the PHP helper/todo." --foreground 214 +elif [[ "$ptv_ok" == "0" && "$smtp_ok" == "0" ]]; then + status_line="BOTH_BAD" + gum style " Both probes failed → the stored credentials themselves are wrong/stale." --foreground 214 +elif [[ "$ptv_ok" == "1" && "$smtp_ok" == "1" ]]; then + status_line="BOTH_OK" + gum style " Both SMTP and PeerTube accept these credentials." --foreground 42 +fi + +# ---- append to log (NEVER the password) ----------------------------------------- +{ + echo "===== $(date '+%Y-%m-%d %H:%M:%S') | $status_line | user=$username | db=$DB_PATH" + echo " SMTP : ok=$smtp_ok err=$(printf '%q' "$smtp_err")" + echo " PeerTube : ok=$ptv_ok err=$(printf '%q' "$ptv_err")" +} >> "$LOG" +gum style "" --foreground 240 +gum style "Logged (no password stored) → $LOG" --foreground 240 + +# Exit 0 only when BOTH probes succeeded. +[[ "$smtp_ok" == "1" && "$ptv_ok" == "1" ]] && exit 0 || exit 1