Files
agent-identity-provisioning/provision-redmine.py
T
TSYS Group COO 2258e1bd04 feat: Redmine SSO login + API key extraction working
Redmine SSO flow: Cloudron login -> click "Continue with KNEL Cloud"
button (#login-oauth-submit-1) -> authenticated. Required Cloudron
admin to grant vp-techops access to the Redmine app first.

API key was already present but hidden. Click "Show" in the
.api-key-actions section to reveal it from the #api-access-key
pre element. Key verified via X-Redmine-API-Key header.
2026-08-13 23:41:00 -05:00

289 lines
12 KiB
Python

#!/usr/bin/env python3
"""
provision-redmine.py -- Redmine SSO login + API key extraction.
Flow:
1. Cloudron login (establish OIDC session)
2. Redmine SSO via "Continue with KNEL Cloud" button
3. Navigate to /my/account
4. Find and generate API key
Usage:
docker compose run --rm --entrypoint python3 provision provision-redmine.py
"""
import os, sys, time, re
from pathlib import Path
from playwright.sync_api import sync_playwright
sys.path.insert(0, os.path.dirname(__file__))
from bw_helper import BitwardenHelper
CLOUDRON_BASE = os.environ.get("CLOUDRON_BASE", "https://my.knownelement.com")
REDMINE_URL = os.environ.get("REDMINE_URL", "https://projects.knownelement.com")
STATE_DIR = Path("/app/state")
BW_ITEM = "vp-techops Cloudron"
REDMINE_BW_ITEM = "vp-techops Redmine"
EMAIL = "tsgstaff-coo-vptechops@turnsys.com"
USERNAME = "vptechops"
def dump(page, label):
ts = time.strftime("%H%M%S")
try:
page.screenshot(path=str(STATE_DIR / f"redmine-{label}-{ts}.png"), full_page=True)
except Exception:
pass
elements = page.evaluate("""() => {
const results = [];
document.querySelectorAll('input, button, a, [role="button"], label, code, pre, .ui.message, #api-access-key, [class*="api"]').forEach(el => {
const tag = el.tagName.toLowerCase();
const text = (el.textContent || '').trim().substring(0, 80);
const id = el.id || '';
const type = el.getAttribute('type') || '';
const value = el.getAttribute('value') || '';
const href = (el.getAttribute('href') || '').substring(0, 50);
const cls = (el.getAttribute('class') || '').substring(0, 60);
const vis = el.offsetParent !== null;
if (text || id || type || value || href) {
results.push('<'+tag+'> id='+id+' type='+type+' value='+value.substring(0,30)+' class='+cls+' vis='+vis+' text="'+text+'"');
}
});
return results;
}""")
body = page.evaluate("() => document.body.innerText.substring(0, 500)")
(STATE_DIR / f"redmine-{label}-{ts}.txt").write_text(f"URL: {page.url}\n\nBody: {body}\n\nElements:\n" + "\n".join(elements))
print(f" [{label}] {len(elements)} elements")
def cloudron_login(page, bw):
password = bw.get_item_password(BW_ITEM)
page.goto(f"{CLOUDRON_BASE}/login.html", wait_until="networkidle", timeout=15000)
page.wait_for_timeout(2000)
if "login" not in page.url.lower() and "openid" not in page.url.lower():
return
page.wait_for_selector("#inputPassword", timeout=15000)
page.click("#inputUsername")
page.keyboard.type(EMAIL)
page.click("#inputPassword")
page.keyboard.type(password)
page.locator('[role="button"]:has-text("Log in")').first.click()
page.wait_for_timeout(3000)
totp = page.query_selector("#inputTotpToken")
if totp and totp.is_visible():
code = bw.get_totp(BW_ITEM)
totp.click()
page.keyboard.type(code)
page.locator("#totpTokenSubmitButton").click()
page.wait_for_timeout(5000)
def main():
bw = BitwardenHelper(
client_id=os.environ["BW_CLIENTID"],
client_secret=os.environ["BW_CLIENTSECRET"],
password=os.environ["BW_PASSWORD"],
server_url=os.environ.get("BW_SERVER", ""),
)
bw.login()
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(viewport={"width": 1280, "height": 1024})
page = context.new_page()
# Step 1: Cloudron login
print("=== STEP 1: Cloudron login ===")
cloudron_login(page, bw)
print(f" Cloudron: {page.url}")
# Step 2: Redmine SSO
print("=== STEP 2: Redmine SSO ===")
page.goto(f"{REDMINE_URL}/login", wait_until="domcontentloaded", timeout=30000)
page.wait_for_timeout(3000)
dump(page, "01-login-page")
# Click SSO button
sso_btn = page.locator('#login-oauth-submit-1')
if sso_btn.count() == 0:
sso_btn = page.locator('button:has-text("KNEL"), button:has-text("Cloud"), button:has-text("Continue")')
if sso_btn.count() > 0 and sso_btn.first.is_visible():
print(f" Clicking SSO button...")
sso_btn.first.click()
page.wait_for_timeout(5000)
dump(page, "02-after-sso-click")
# Handle OIDC consent if needed
if "openid" in page.url.lower():
print(f" OIDC page: {page.url}")
for consent in ["Continue", "Authorize", "Allow", "Accept"]:
cbtn = page.locator(f'[role="button"]:has-text("{consent}"), button:has-text("{consent}")')
if cbtn.count() > 0 and cbtn.first.is_visible():
cbtn.first.click()
page.wait_for_timeout(5000)
print(f" Clicked consent: {consent}")
break
dump(page, "03-after-consent")
else:
print(" SSO button not found!")
current_url = page.url
body = page.evaluate("() => document.body.innerText.substring(0, 300)")
print(f" Current URL: {current_url}")
print(f" Body: {body[:200]}")
if "/login" in current_url:
print(" STILL ON LOGIN PAGE -- SSO may have failed")
browser.close()
return
print(" Redmine SSO: SUCCESS!")
# Step 3: Navigate to account page for API key
print("=== STEP 3: Navigate to /my/account ===")
page.goto(f"{REDMINE_URL}/my/account", wait_until="domcontentloaded", timeout=30000)
page.wait_for_timeout(3000)
dump(page, "04-account-page")
body = page.evaluate("() => document.body.innerText")
print(f" Account body: {body[:300]}")
# Step 4: Look for existing API key or generate new one
print("=== STEP 4: Find/generate API key ===")
# Check if API key already exists on the page
api_key = ""
api_key_el = page.query_selector('#api-access-key, .api-access-key')
if api_key_el:
text = api_key_el.text_content().strip()
# Redmine API keys are 40-char hex or alphanumeric
matches = re.findall(r'[a-f0-9]{40}|[A-Za-z0-9]{40}', text)
if matches:
api_key = matches[0]
print(f" Found existing API key: {api_key[:12]}...")
if not api_key:
# Look in page text for the key
matches = re.findall(r'\b([a-f0-9]{40})\b', body)
if matches:
api_key = matches[0]
print(f" Found API key in page text: {api_key[:12]}...")
if not api_key:
# Try clicking "Show" first to reveal an existing key
show_btn = page.locator('.api-key-actions a:has-text("Show"), a:has-text("Show")')
if show_btn.count() > 0 and show_btn.first.is_visible():
print(" Clicking Show to reveal existing key...")
show_btn.first.click()
page.wait_for_timeout(2000)
# Check the now-visible #api-access-key pre element
api_el = page.query_selector('#api-access-key')
if api_el:
text = api_el.text_content().strip()
matches = re.findall(r'[a-f0-9]{40}', text)
if matches:
api_key = matches[0]
print(f" Revealed API key: {api_key[:12]}...")
if not api_key:
# Generate via the Reset link in the API access key section.
# Use JS to find the Reset link that is a sibling of #api-access-key's container.
print(" No key visible, clicking API key Reset...")
reset_clicked = page.evaluate("""() => {
// Find the API access key section and its Reset link
const apiSection = document.querySelector('#api-access-key');
if (!apiSection) return false;
// Walk up to the parent container, then find Reset link within it
let container = apiSection.closest('div, p, fieldset');
while (container && container.parentElement) {
const reset = Array.from(container.querySelectorAll('a, button')).find(el =>
el.textContent.trim().toLowerCase() === 'reset' && el.offsetParent !== null
);
if (reset) {
reset.click();
return true;
}
container = container.parentElement;
if (container.tagName === 'FIELDSET' || container.tagName === 'FORM') break;
}
return false;
}""")
if reset_clicked:
page.wait_for_timeout(3000)
dump(page, "05-after-reset")
# Handle confirmation dialog if Redmine asks
confirm = page.locator('button:has-text("OK"), button:has-text("Confirm"), button:has-text("Yes"), input[value="OK"], input[value="Yes"]')
if confirm.count() > 0 and confirm.first.is_visible():
print(" Clicking confirmation...")
confirm.first.click()
page.wait_for_timeout(3000)
dump(page, "06-after-confirm")
# Now check for the key
body = page.evaluate("() => document.body.innerText")
# Try #api-access-key element first
api_el = page.query_selector('#api-access-key')
if api_el:
text = api_el.text_content().strip()
matches = re.findall(r'[a-f0-9]{40}', text)
if matches:
api_key = matches[0]
print(f" Generated API key: {api_key[:12]}...")
# Fallback: scan full body
if not api_key:
matches = re.findall(r'\b([a-f0-9]{40})\b', body)
if matches:
api_key = matches[0]
print(f" Found API key in body: {api_key[:12]}...")
else:
print(" Could not find API key Reset link via DOM traversal")
# Also try data attributes
if not api_key:
api_el = page.query_selector('[data-key], [data-api-key]')
if api_el:
api_key = api_el.get_attribute("data-key") or api_el.get_attribute("data-api-key") or ""
if api_key:
print(f" Found in data attr: {api_key[:12]}...")
if api_key:
print(f"\n API KEY: {api_key}")
print("=== STEP 5: Store in Bitwarden ===")
existing = bw.get_item_id(REDMINE_BW_ITEM)
if existing:
bw.update_item(REDMINE_BW_ITEM, password=api_key)
print(f" Updated BW item '{REDMINE_BW_ITEM}'")
else:
bw.create_item(
name=REDMINE_BW_ITEM,
username=USERNAME,
password=api_key,
uris=[REDMINE_URL],
collection_name="default",
)
print(f" Created BW item '{REDMINE_BW_ITEM}'")
else:
print(" Could not extract API key")
# Dump all elements with 'api' in their attributes
api_elements = page.evaluate("""() => {
return Array.from(document.querySelectorAll('*')).filter(el => {
const id = (el.id || '').toLowerCase();
const cls = (el.getAttribute('class') || '').toLowerCase();
return id.includes('api') || cls.includes('api');
}).map(el => ({
tag: el.tagName,
id: el.id,
cls: el.getAttribute('class') || '',
text: (el.textContent || '').trim().substring(0, 80),
}));
}""")
print(f" API-related elements: {api_elements}")
browser.close()
print("\n=== DONE ===")
if __name__ == "__main__":
main()