mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-09-25 01:53:03 +02:00
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:
@@ -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/
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
|
||||
Executable
+153
@@ -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
|
||||
Executable
+116
@@ -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);
|
||||
Executable
+139
@@ -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
|
||||
Reference in New Issue
Block a user