diag: invalid_grant is SSO auth-method mismatch, not bad creds

- feat: creds-test.sh gum probe for SMTP vs PeerTube auth + PeerTubeService::probeAuth()
- feat: app-token.sh gum probe for long-lived PeerTube app token (client_credentials)
- docs: add copy-paste proof commands to demonstrate the SSO break to admins
This commit is contained in:
Pontoporeia
2026-08-24 11:33:34 +02:00
parent 5460041989
commit 7b6d79c133
8 changed files with 748 additions and 1 deletions
+3
View File
@@ -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/
+9 -1
View File
@@ -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)
- [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.
+26
View File
@@ -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
// -------------------------------------------------------------------------
+289
View File
@@ -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=<the SMTP 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_id>&client_secret=<client_secret>&username=xamxam@erg.be&password=<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
```
+13
View File
@@ -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 <id> --secret <secret>
@bash scripts/app-token.sh
[group('utils')]
clean:
@rm -f app/error.log
+153
View File
@@ -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 <id> --secret <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 <url>] [--client <id>] [--secret <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 <client_id> --secret <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
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env php
<?php
/**
* creds-probe.php
*
* CLI helper used by scripts/creds-test.sh. Performs two live probes using the
* credentials stored in the database (SMTP username/password), WITHOUT the SSO
* portal in the middle:
*
* 1. SMTP — real TCP connect + SMTP AUTH, then disconnect (no mail sent)
* 2. PeerTube — OAuth2 "password" grant against <instance>/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 <path>] [--with-pwd] [--instance <url>] [--channel <handle>]
*
* 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);
+139
View File
@@ -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 <instance>/api/v1/users/token
#
# Every run is appended to a log without the plaintext password.
#
# Usage:
# scripts/creds-test.sh [--db <path>] [--instance <url>] [--channel <handle>]
# --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 <path>] [--instance <url>] [--channel <handle>] [--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