skills: agent-provisioning - codified identity stand-up workflow

Policy rulings (#942): unique passwords, Cloudron-only SSO accounts,
no reachableceo credentials, TOTP seeds MUST be captured to the vault.
Cloudron admin API wrapper, cookie-jar OIDC login, RFC-6238-tested
TOTP helper, per-system SSO/API references, incident log.
This commit is contained in:
TSYS Group COO
2026-09-08 04:07:14 -05:00
parent a4a54f553e
commit bde7845490
8 changed files with 503 additions and 0 deletions
+166
View File
@@ -0,0 +1,166 @@
---
name: agent-provisioning
description: >
Provision TSYS Group agent identities end-to-end: Cloudron account (unique
password, TOTP with seed captured to the TSGCOO vault), SSO first-login into
Redmine / Gitea / Discourse / GLPI, per-system API key minting, vault item
schema, and verification. Use whenever asked to "stand up" an identity or
agent, create/fix Cloudron users, wire SSO, debug identity logins or 2FA,
or store identity credentials — even if the word "provisioning" is not used.
Policy-critical: encodes founder rulings #942 (unique passwords, Cloudron-only
accounts, no reachableceo credentials).
---
# Agent identity provisioning (TSYS Group)
Stand up an agent identity so it can operate its own accounts in Cloudron and
the four SSO-wired systems, with every credential landing in the TSGCOO
Bitwarden vault. The Cloudron is the ONLY identity source: never create local
accounts in Redmine, Gitea, Discourse, or GLPI — the system account comes into
existence when the identity completes its first SSO login there.
## Non-negotiable policy (founder rulings — violating these invalidates the work)
1. **Unique password per identity.** One identity, one password, used only for
its Cloudron account. Shared passwords are banned (Redmine #942).
2. **Cloudron-only accounts.** System access = SSO first-login. No local
accounts, no admin-created users in the downstream systems.
3. **Never use reachableceo credentials.** Items like `creds/gitea-admin`,
`creds/gitea`, `creds/redmine` are the founder's personal accounts and
carry RESTRICTED notes. If an auth test resolves to user `reachableceo`,
stop — you are holding the founder's identity.
4. **TOTP seeds MUST be stored in Bitwarden** (founder ruling 2026-09-08).
Enroll TOTP and write the base32 seed to the vault item in the same
breath — a seed that is not in the vault is treated as lost, because
there is no admin 2FA reset on this Cloudron and no other record exists.
5. **Secrets live only in the vault** (KNELSecretsManager `sm` CLI) — never in
repos, command output, logs, or transcripts. Mask credentials in every
command's visible output.
6. **Bookkeeping in the same turn:** Redmine ticket on the provisioning
program (#942 or its successor), cross-linked; vault item created;STATUS
updated.
## Scope check
Identity stand-up is the COO C2 lane's assigned program (founder directive,
2026-09-08). If your chat runs in another lane (SRE, security, OAM), confirm
the founder assigned the stand-up to you before executing, or route a ticket
to the COO C2 lane instead.
## Admin lever: Cloudron API token
`creds/cloudron` holds a working admin API token (service user `api-token`,
64 chars). Use it as `Authorization: Bearer $PASSWORD`. Do NOT log into the
dashboard interactively for admin work — the token is the compliant path.
```bash
scripts/cloudron-api.sh GET /users?limit=100 # list users (note uid- UUIDs)
scripts/cloudron-api.sh GET /users/<uid> # fetch one user object
```
Route notes (this Cloudron, 2026-09): users are addressed by `uid-…` UUIDs,
not usernames; there is NO `/api/v1/login` (dashboard auth is its own OIDC
flow); there is NO admin route to reset another user's 2FA — so the seed
capture in step 2 is not optional.
## Procedure
### 0. Prerequisites
- Vault reachable: `sm get creds/cloudron` works (see references/vault.md).
- For a NEW Cloudron user: the founder generates the invite, or you create the
user via the admin token if the founder has authorized self-service.
- A Redmine ticket to record the work (create one if none exists).
### 1. Cloudron account (unique password)
Verify the account exists and matches the identity email pattern
(`tsgstaff-<login>@turnsys.com` for COO-line identities). Generate the unique
password (32 chars), set it, and confirm a login works (step 2 proves it).
### 2. TOTP 2FA — enroll and CAPTURE THE SEED
Enroll TOTP via the profile self-service surface and write the base32 seed to
the vault item before anything else:
```bash
sm setfield "<login> Cloudron" totp_seed "<BASE32SEED>"
```
If the identity already has 2FA enforced and no seed exists anywhere: STOP and
ask the founder. There is no programmatic recovery (verified 2026-09-08).
Compute codes offline when needed: `scripts/totp.sh <seed>`.
### 3. SSO first-login into each system
The interactive login is scriptable with a cookie jar — no browser needed.
`scripts/oidc-login.sh <system>` runs the whole flow and leaves a session
cookie jar at `/tmp/oidc-<login>-<system>.txt`. Entry points:
| System | SSO start point | After login |
|---|---|---|
| GLPI | `https://cmdb.knownelement.com/plugins/singlesignon/front/callback.php/provider/1?remember=1` | account auto-provisioned |
| Gitea | `https://git.knownelement.com/user/oauth2/cloudron` | account auto-provisioned |
| Discourse | `https://community.turnsys.com/auth/openid_connect` | account auto-provisioned |
| Redmine | `https://projects.knownelement.com/oauth` | account auto-provisioned (if the bounce in references/systems.md is hit, see that file) |
Key gotcha (cost a debugging session — do not rediscover): the OIDC
interaction login is `POST /openid/interaction/<uid>/login` with JSON body
`{"username": "...", "password": "..."}` — the field is **`username`**, not
`login`, and the response is `{"redirectTo": ...}` on success.
### 4. Mint per-system API keys (as the identity, via its session)
- **Redmine**: My account → API key (visible to self); also returned by
`GET /users/current.json` with the session.
- **Gitea**: Settings → Applications → new token with write scopes; or
`POST /api/v1/users/<login>/tokens` using the session (requires basic auth
of the same user for token endpoints).
- **Discourse**: user API key flow; or admin-generated key if an admin lever
is ever provided (none currently is — do not use founder keys).
- **GLPI**: My account → API token (personal token); pair with the app token
from `creds/glpi` (password field) for REST calls.
Store each key in the vault item immediately (schema below).
### 5. Vault item schema — `creds/<login>`
One item per identity (NOT per system). Create it as a LOGIN item with:
- login username = Cloudron username; password = the unique Cloudron password
- URI = `https://my.knownelement.com`
- fields: `email`, `organization`, `role`, `reports_to`, `created`,
`services`, plus per-system: `redmine_api_key`, `gitea_token`,
`discourse_user_api_key`, `glpi_user_token`, and `totp_seed`
Historical items that predate this schema (e.g. `sec-*`, `sre-*` shared-
password items) are remediation targets under #942 — upgrade them when the
owner lane assigns the work, do not replicate their shape.
### 6. Verify the trail
Prove each system knows the identity (expected: 200 + the identity's own
name; a 200 from the wrong identity is a FAIL):
```bash
curl -sf -H "X-Redmine-API-Key: $KEY" https://projects.knownelement.com/users/current.json | jq .user.login
curl -sf -H "Authorization: token $TOKEN" https://git.knownelement.com/api/v1/user | jq .login
```
Then update the Redmine ticket (full URLs, `[agent] <your-identity>` note) and
the workspace STATUS/memory.
## References — read before touching the corresponding system
- `references/cloudron.md` — admin API routes, OIDC interaction flow, 2FA.
- `references/systems.md` — per-system SSO/API details and known breakage.
- `references/vault.md` — sm CLI entry points, gotchas, fallbacks.
- `references/incident-log.md` — what went wrong on 2026-09-07/08 and why, so
the same failures are not re-derived.
## Scripts
- `scripts/cloudron-api.sh METHOD PATH [JSON]` — authenticated admin call.
- `scripts/oidc-login.sh <glpi|gitea|discourse|redmine> <login>` — full SSO
login via cookie jar; reads the identity's creds from the vault.
- `scripts/totp.sh <base32-seed>` — current 6-digit TOTP code (pure bash).
@@ -0,0 +1,62 @@
# Cloudron admin API + OIDC (verified 2026-09-08)
## Instance
- Dashboard: `https://my.knownelement.com` (admin surface; this host also
fronts the org's public vhosts — it is NOT the tailscale-only control box).
- API token: vault item `creds/cloudron`, password field, 64 chars, service
user `api-token`. Verified: reads full user directory.
## Routes (this is a newer Cloudron — older route knowledge may not apply)
- Users are addressed by `uid-<uuid>`, never username.
`GET /api/v1/users?limit=100` → list; `GET /api/v1/users/<uid>` → object
(`twoFactorAuthenticationEnabled` is null/absent on objects even when
enforced — the OIDC interaction is the source of truth).
- There is NO `POST /api/v1/login`. Dashboard auth is its own OIDC browser
flow; the SPA bundle (as of 2026-09-08) contains no login route.
- There is NO admin route to reset another user's 2FA (PUT/PATCH/DELETE on
`/users/<uid>` 2FA variants all 404; the dashboard UI only offers profile
self-service enrollment). Consequence: TOTP seeds not captured at
enrollment are unrecoverable without the founder.
- Other useful routes seen in the SPA bundle: `/api/v1/tokens`,
`/api/v1/oidc/clients`, `/api/v1/groups`, `/api/v1/eventlog`,
`/api/v1/app_passwords`. Verify behavior at runtime before relying on a
specific payload shape.
## OIDC interaction login (how identity SSO works)
The GLPI/Gitea/Discourse/Redmine SSO flows all redirect here:
1. `GET <app SSO start point>` → 302 to
`https://my.knownelement.com/openid/interaction/<uid>` (Cloudron runs a
node-oidc-provider at `/openid`). Cookies matter — keep a cookie jar.
2. `POST /openid/interaction/<uid>/login` with JSON
`{"username": "<login>", "password": "<unique password>"}`.
- Field name is **`username`** — `login` returns
`"A username must be non-empty string"` (400).
- Success → `{"redirectTo": "..."}` (or
`{"twoFactorRequired":true,"totpRequired":true}` if 2FA is enabled).
- Wrong credentials → 401 `{"status":"Unauthorized","message":"Username
and password does not match"}`.
3. `GET` the `redirectTo` (same cookie jar) → completes at the app.
Email-style usernames work (`tsgstaff-coo@turnsys.com`); bare usernames also
exist (`coo`). Identity emails follow `tsgstaff-<login>@turnsys.com`.
## 2FA
- TOTP enforced per user at the interaction step
(`twoFactorRequired`/`totpRequired` in the login response).
- Enrollment is profile self-service ONLY. Capture the base32 seed into the
vault (`sm setfield "<login> Cloudron" totp_seed <seed>`) at enrollment —
there is no admin reset and no recovery path.
- Policy RULED (founder, 2026-09-08): TOTP seeds MUST be stored in Bitwarden.
Enroll 2FA only when the seed is being captured to the vault item in the
same step. Legacy identities whose seeds were never captured (the whole
August batch) are reset-on-sight candidates: founder clears 2FA or hands
over the seed, then re-enroll with capture.
---
**Records:** Redmine #942 — https://projects.knownelement.com/issues/942 · Discourse docs home: https://community.turnsys.com (skill topic pending admin-key provisioning, tracked on #942)
@@ -0,0 +1,52 @@
# Incident log — what broke and what it taught (append-only)
## 2026-09-07 ~20:00 CT — shared-box degradation (RESOLVED 09-08)
One box (152.53.37.179, mail.knownelement.com) hosts gitea, redmine,
discourse, GLPI, pwvault, and fronts my.knownelement.com. During the
incident:
- Gitea `git-receive-pack` → HTTP 500 on ANY ref (pushes impossible);
`POST /api/v1/repos` → plain-text 404. Reads + `POST /api/v1/markdown`
worked fine — partial failure, not an outage.
- my.knownelement.com → HTTP 500 leaking
`"Access denied for user 'root'@'localhost'"` (a MySQL root credential
breakage in whatever backs that vhost).
- pwvault: unauthenticated endpoints answered in 0.2s, but AUTHENTICATED
`/api/sync` hung to timeout — a lock/db-layer signature. All vault item
reads fleet-wide were down; nothing credential-driven could proceed.
- tsys-cloudron (tailscale 100.107.35.78, self-signed cert — use `curl -k`)
404'd its hostname; with `Host: my.knownelement.com` it served the same
JSON as the public vhost (proxy topology). devbox/preprod/sectestbed
cloudron peers were offline for 12 days.
Lessons: (1) when several "unrelated" systems on that box fail at once,
treat it as one box incident — file one ticket, verify recovery per system;
(2) HTTP-status-only checks lie — the vault was "up" (200s on static paths)
while 100% of useful reads hung; test the authenticated path you actually
need; (3) correlate carefully — the MySQL leak was the public box's vhost,
not the cloudron control box; wrong attribution sent diagnosis down a
rabbit hole (see #939 correction).
## 2026-09-07 — identity/SSO archaeology (the reason this skill exists)
- The August "10 identities enrolled + TOTP" batch captured NO seeds. coo's
TOTP is therefore enforced-but-unrecoverable; no admin reset exists in
this Cloudron version. Every future enrollment MUST store the seed.
- `creds/sec-*` / `creds/sre-*` (Sep 07 batch, by VpEngOps) used ONE shared
password across cloudron+gitea+redmine+discourse, and the stored password
already fails Cloudron auth (401 tested). Shared passwords are banned
(#942); treat those items as stale until their owning lane remediates.
- Several "admin" vault candidates resolved to the founder's personal
reachableceo accounts (gitea id 3, redmine user 5) and none were admin
(gitea: 403 "user should be the site admin"; redmine: 403 on /users.json).
They now carry RESTRICTED notes. The compliant Cloudron admin lever is the
`api-token` user's token in `creds/cloudron`.
- Debug order that works for auth weirdness: (1) what identity does this
credential ACTUALLY resolve to (call /user or /users/current)?
(2) does the route exist at all (SPA-bundle grep beats guessing)?
(3) is the failure at nginx (HTML status pages) or the app (JSON errors)?
---
**Records:** Redmine #942 — https://projects.knownelement.com/issues/942 · Discourse docs home: https://community.turnsys.com (skill topic pending admin-key provisioning, tracked on #942)
@@ -0,0 +1,73 @@
# Per-system SSO + API key details (verified 2026-09-07/08 unless noted)
## GLPI (cmdb.knownelement.com) — SSO VERIFIED WORKING
- SSO: "Login with KNEL Cloud" = plugin route
`/plugins/singlesignon/front/callback.php/provider/1?remember=1` → Cloudron
OIDC → auto-provision.
- The login page defaults to whichever tab was last toggled ("Use GLPI login
form" / "Use Single Sign-on"); a hidden `noAUTO=1` input forces the local
form — irrelevant under Cloudron-only policy, useful to recognize.
- API: GLPI 11 is OAuth/token-based. The legacy REST at
`/apirest.php/initSession` exists: password login is DISABLED (400);
user_token auth exists but the tokens in `creds/glpi` / `creds/mglpi` are
STALE (401 ERROR_GLPI_LOGIN_USER_TOKEN). The app token in `creds/glpi`
password field IS accepted (used as `App-Token` header).
- Per-user API token: My account (self-service) after SSO provisioning. Pair
with the app token: `GET /apirest.php/initSession` with headers
`App-Token: <app>` + `Authorization: user_token <user>`.
- Local web login as `glpi` super-admin hits a 6-digit TOTP at
`/MFA/Prompt` whose secret is not stored anywhere — do not rely on the
admin UI; the SSO path makes it unnecessary.
## Gitea (git.knownelement.com)
- SSO: `/user/oauth2/cloudron` (button "Sign in with …" on the login page).
- Standalone install behind nginx on the shared box — local accounts exist
from the August batch; Cloudron-only policy means new identities use SSO
and legacy ones migrate.
- API: tokens via `POST /api/v1/users/<login>/tokens` REQUIRE basic auth AS
that user (admin tokens cannot mint for others; there is no
`/admin/users/<name>/tokens` route; `Sudo` header needs a real site admin).
- Token auth against `/api/v1/user`: the JSON has `login`; admin-ness shows
in `site_admin` (absent = not admin; do not misread null as false).
- KNOWN BROKEN (2026-09-07/08): receive-pack 500s during the box incident —
RESOLVED after the 09-08 repair (pushes verified). If pushes 500 again,
check the shared box first (see incident-log.md).
## Discourse (community.turnsys.com)
- SSO: `/auth/openid_connect` — a bare GET returns 403 (CSRF-gated); that is
NORMAL and means the route exists. Complete the flow from the login page
button or with a session + CSRF token.
- The login page is an Ember SPA: server-rendered HTML shows no SSO markers,
which fooled one audit (2026-09-07) into "no SSO" — wrong. Check routes,
not page source.
- API: user API keys (User-Api-Key header) minted via the user-approval flow;
admin Api-Key + Api-Username impersonation needs an admin key. The vault's
`creds/discourse` NOTE says its keys are NOT admin (they are also not hex —
do not assume key format). `creds/vptechops-discourse` holds a WORKING
user key (user `vptechopsvptechops`) — #933's "dead key" note is wrong.
## Redmine (projects.knownelement.com)
- SSO: "Continue with KNEL Cloud" button POSTs/GETs `/oauth`.
KNOWN BROKEN: `/oauth` 302-bounces straight back to /login with no
provider redirect — verified 2026-09-07 AND 2026-09-08 (IdP healthy both
times), so it is a Redmine plugin configuration issue, NOT the IdP.
Retest before relying on it; until fixed, Redmine accounts cannot be
created via SSO.
- API keys: `GET /users/current.json` (session or basic auth) returns the
user's `api_key`; X-Redmine-API-Key header for calls. Vault convention:
the API key is stored in the item's password field.
- Admin surface: `/users.json` lists users (admin only — 403 otherwise).
Every admin candidate tested in the vault so far is NON-admin (they are
all the founder's personal accounts anyway — RESTRICTED).
## Shared infrastructure hazard
Gitea, Redmine, Discourse, GLPI, the pwvault, and the my. vhost all run on
ONE box (152.53.37.179 = mail.knownelement.com). Symptoms there cluster:
when several systems misbehave at once, suspect the box (see
incident-log.md), file/update one ticket, and do not burn hours debugging
four clients.
@@ -0,0 +1,48 @@
# TSGCOO vault (`sm` CLI) — entry points and gotchas
The KNELSecretsManager vault (Vaultwarden at pwvault.turnsys.com, account
coo@turnsys.com) is the ONLY credential store. Full doctrine:
OAM workspace memory `tsgcoo-account-ops` + `creds-migration`; tool
reference: KNEL workspace memory `knelsecretsmanager-reference`.
## Invocation
```bash
# from the reachableceo account (this workstation):
sudo -u TSGCOO /data2/TSGCOO/.local/bin/sm <cmd> # TSGCOO crossover
# from inside the TSGCOO account:
/data2/TSGCOO/.local/bin/sm <cmd>
# raw container form:
docker exec -i ukrrs-secretsmgr-cli smcli <cmd>
```
Commands: `status`, `list [pattern]`, `get <name> [--field KEY]`,
`env <name>`, `set`, `setfield <name> <key> <value>`, `rm`, `folders`.
Item naming: `creds/<name>` for services; `<login> <System>` for identity
login items; `creds/<login>` for the new per-identity schema.
## Gotchas that have actually bitten
1. `sm env` / `sm get` in scripts: append `</dev/null` — docker exec -i eats
stdin and hangs the call inside loops/pipelines.
2. `--field URI` NEVER matches — URIs are login URIs, not custom fields. Use
`sm env` (synthetic URI/USERNAME/PASSWORD) or `--field password`.
3. `sm env` PASSWORD is the login password, which for token-style items IS
the token (e.g. `creds/cloudron`). Always confirm what a field holds
before assuming password-vs-token semantics.
4. zsh does not word-split `$VAR``SM="sudo -u ... sm"; $SM get` fails with
"no such file". Use a function: `smget() { sudo -u TSGCOO /data2/TSGCOO/.local/bin/sm "$@" </dev/null; }`.
5. Mask secrets in ALL visible output: pipe through
`sed -E 's/((password|token|secret|key|seed|totp)[^:=]*[=:]).*/\1 [MASKED]/Ig'`
and jq projections of field NAMES/lengths, never values.
6. `smcli` self-heals expired sessions (v13: refresh-token rotation +
fallback relogin using injected env). If `smcli status` is fine but
fetches hang (`/api/sync` context deadline), the vault server is sick —
stop retrying, check the shared box (incident-log.md), escalate.
7. Never print a filled password form's DOM snapshot (browser work) — the
ARIA tree includes field values; one coo password hit a transcript this
way (2026-09-07).
---
**Records:** Redmine #942 — https://projects.knownelement.com/issues/942 · Discourse docs home: https://community.turnsys.com (skill topic pending admin-key provisioning, tracked on #942)
+23
View File
@@ -0,0 +1,23 @@
#!/bin/bash
# Authenticated Cloudron admin API call (compliant lever: api-token service
# account from vault item creds/cloudron — never the founder's accounts).
# usage: cloudron-api.sh METHOD /api/v1/PATH [JSON-BODY]
# body -> stdout; HTTP status -> stderr. Secrets never printed.
set -euo pipefail
BASE="https://my.knownelement.com"
METHOD="${1:?usage: cloudron-api.sh METHOD /api/v1/PATH [JSON-BODY]}"
PATHPART="${2:?usage: cloudron-api.sh METHOD /api/v1/PATH [JSON-BODY]}"
BODY="${3:-}"
if [ "$(id -un)" = "TSGCOO" ]; then
SM() { /data2/TSGCOO/.local/bin/sm "$@" </dev/null; }
else
SM() { sudo -u TSGCOO /data2/TSGCOO/.local/bin/sm "$@" </dev/null; }
fi
TOKEN=$(SM get creds/cloudron --field password)
[ -n "$TOKEN" ] || { echo "no cloudron token in vault" >&2; exit 1; }
ARGS=(-sk -X "$METHOD" "$BASE$PATHPART"
-H "Authorization: Bearer $TOKEN"
-H 'Content-Type: application/json'
-w '\nHTTP %{http_code}\n')
[ -n "$BODY" ] && ARGS+=(-d "$BODY")
curl "${ARGS[@]}" >&2
+59
View File
@@ -0,0 +1,59 @@
#!/bin/bash
# Full SSO login for an identity into one of the four systems, via cookie jar.
# usage: oidc-login.sh <glpi|gitea|discourse|redmine> <login>
# Reads the Cloudron password from vault item "<login> Cloudron" (env login
# triple; USERNAME may be the email form). Success leaves cookie jar at
# /tmp/oidc-<login>-<system>.txt and prints the landing URL.
# TOTP-enforced identity? Set TOTP_CODE=$(scripts/totp.sh <seed>) first.
set -euo pipefail
SYS="${1:?usage: oidc-login.sh <glpi|gitea|discourse|redmine> <login>}"
LOGIN="${2:?usage: oidc-login.sh <glpi|gitea|discourse|redmine> <login>}"
case "$SYS" in
glpi) START="https://cmdb.knownelement.com/plugins/singlesignon/front/callback.php/provider/1?remember=1"; APP="https://cmdb.knownelement.com";;
gitea) START="https://git.knownelement.com/user/oauth2/cloudron"; APP="https://git.knownelement.com";;
discourse) START="https://community.turnsys.com/auth/openid_connect"; APP="https://community.turnsys.com";;
redmine) START="https://projects.knownelement.com/oauth"; APP="https://projects.knownelement.com";;
*) echo "unknown system: $SYS" >&2; exit 2;;
esac
if [ "$(id -un)" = "TSGCOO" ]; then
SM() { /data2/TSGCOO/.local/bin/sm "$@" </dev/null; }
else
SM() { sudo -u TSGCOO /data2/TSGCOO/.local/bin/sm "$@" </dev/null; }
fi
eval "$(SM env "$LOGIN Cloudron")"
[ -n "${PASSWORD:-}" ] || { echo "vault item '$LOGIN Cloudron' empty" >&2; exit 1; }
USER_FIELD="${USERNAME:-$LOGIN}"
JAR="/tmp/oidc-${LOGIN}-${SYS}.txt"; rm -f "$JAR"
# 1. Start the SSO flow; follow redirects to the OIDC interaction page.
INTURL=$(curl -s -o /dev/null -w '%{redirect_url}' -c "$JAR" "$START")
case "$INTURL" in
https://my.knownelement.com/openid/interaction/*) : ;;
*) echo "no interaction redirect (got: '${INTURL:-none}'). If this is redmine, see references/systems.md (known bounce). If discourse, the route may need CSRF via a real session." >&2; exit 3;;
esac
# 2. Interaction login. Field name is `username` (NOT `login`).
BODY="{\"username\":\"$USER_FIELD\",\"password\":\"$PASSWORD\""
if [ -n "${TOTP_CODE:-}" ]; then BODY="$BODY,\"totpToken\":\"$TOTP_CODE\""; fi
BODY="$BODY}"
RESP=$(curl -s -b "$JAR" -c "$JAR" -X POST "${INTURL}/login" \
-H 'Content-Type: application/json' -d "$BODY")
unset BODY
case "$RESP" in
*redirectTo*)
RED=$(printf '%s' "$RESP" | sed -n 's/.*"redirectTo":"\([^"]*\)".*/\1/p')
case "$RED" in http*) : ;; *) RED="$APP$RED" ;; esac
FINAL=$(curl -s -L -b "$JAR" -c "$JAR" -o /dev/null -w '%{url_effective}' "$RED")
echo "logged in: cookie jar $JAR"
echo "landing: $FINAL"
echo "verify the identity before use (users/current or /api/v1/user)."
;;
*twoFactorRequired*)
echo "2FA enforced and no/invalid TOTP_CODE given." >&2
echo "If the seed is in the vault: TOTP_CODE=\$(scripts/totp.sh \$(sm get '$LOGIN Cloudron' --field totp_seed)) $0 $SYS $LOGIN" >&2
exit 4;;
*)
echo "login rejected: $RESP" >&2
exit 5;;
esac
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
# Current TOTP code from a base32 seed (pure bash + openssl + coreutils).
# usage: totp.sh <base32-seed> [step-seconds] [digits]
set -euo pipefail
SECRET="${1:?usage: totp.sh <base32-seed> [step] [digits]}"
STEP="${2:-30}"; DIGITS="${3:-6}"
NOW="${TOTP_NOW:-$(date +%s)}" # TOTP_NOW: RFC-6238 test vector override
KEYHEX=$(printf '%s' "$SECRET" | tr -d ' =\n-' | base32 -d 2>/dev/null | od -An -tx1 | tr -d ' \n')
[ -n "$KEYHEX" ] || { echo "bad base32 seed" >&2; exit 2; }
COUNTER=$(printf '%016x' $(( NOW / STEP )))
# bash vars cannot hold null bytes - keep the counter as a FORMAT string and
# let printf emit the raw bytes straight into the pipe.
FMT=$(printf '%s' "$COUNTER" | sed 's/../\\x&/g')
# intentional: FMT emits raw counter bytes incl. NULs - the whole point
# shellcheck disable=SC2059
MAC=$(printf "$FMT" | openssl dgst -sha1 -mac HMAC -macopt "hexkey:$KEYHEX" -binary \
| od -An -tx1 | tr -d ' \n')
OFFSET=$(( 0x${MAC: -1} ))
CODE=$(( (0x${MAC:$((OFFSET*2)):8} & 0x7fffffff) % (10 ** DIGITS) ))
printf "%0${DIGITS}d\n" "$CODE"