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.
This commit is contained in:
@@ -20,3 +20,4 @@ services:
|
|||||||
- ./dump-sso-flows.py:/app/dump-sso-flows.py:ro
|
- ./dump-sso-flows.py:/app/dump-sso-flows.py:ro
|
||||||
- ./provision-discourse.py:/app/provision-discourse.py:ro
|
- ./provision-discourse.py:/app/provision-discourse.py:ro
|
||||||
- ./provision-discourse-apikey.py:/app/provision-discourse-apikey.py:ro
|
- ./provision-discourse-apikey.py:/app/provision-discourse-apikey.py:ro
|
||||||
|
- ./provision-redmine.py:/app/provision-redmine.py:ro
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
#!/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()
|
||||||
Reference in New Issue
Block a user