ansible-taler-exchange

Ansible playbook to deploy a production Taler Exchange
Log | Files | Refs | Submodules | README | LICENSE

commit ad3e519f37747ac7bb64ca3a8aecf5aa2baef1c1
parent 293607ced2ef241f57a9db8aa88916bd356b0186
Author: Florian Dold <florian@dold.me>
Date:   Mon,  6 Jul 2026 23:06:11 +0200

devtesting for challenger

Diffstat:
Mroles/devtesting/tasks/files/etc/sudoers.d/devtesting | 1+
Mroles/devtesting/tasks/files/taler-devtesting | 15+++++++++++++++
Aroles/devtesting/tasks/files/taler-devtesting-exchange-challenger-setup | 306+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mroles/devtesting/tasks/main.yml | 9+++++++++
4 files changed, 331 insertions(+), 0 deletions(-)

diff --git a/roles/devtesting/tasks/files/etc/sudoers.d/devtesting b/roles/devtesting/tasks/files/etc/sudoers.d/devtesting @@ -1 +1,2 @@ devtesting ALL=(libeufin-nexus:libeufin-nexus) CWD=/tmp/ NOPASSWD: /usr/bin/libeufin-nexus testing * +devtesting ALL=(taler-exchange-httpd:taler-exchange-kyc) CWD=/tmp/ NOPASSWD: /usr/local/bin/taler-devtesting-exchange-challenger-setup * diff --git a/roles/devtesting/tasks/files/taler-devtesting b/roles/devtesting/tasks/files/taler-devtesting @@ -80,6 +80,21 @@ def geniban(): ] ) +@cli.command() +@click.argument('section', nargs=1, type=click.STRING) +def challenger(section): + subprocess.run( + [ + "sudo", + "-u", + "taler-exchange-httpd", + "taler-devtesting-exchange-challenger-setup", + "-c", + "/etc/taler-exchange/taler-exchange.conf", + section + ], + check=True, + ) if __name__ == "__main__": orig_cmd = os.environ.get("SSH_ORIGINAL_COMMAND") diff --git a/roles/devtesting/tasks/files/taler-devtesting-exchange-challenger-setup b/roles/devtesting/tasks/files/taler-devtesting-exchange-challenger-setup @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +""" +Taler Challenger Address Verification Setup Tool +================================================= +This script reads the configuration for a KYC provider section from a taler-exchange config, +sends a setup request to the Challenger API, and returns the address verification URL. +""" + +import argparse +import json +import logging +import secrets +import shutil +import subprocess +import sys +import urllib.error +import urllib.parse +import urllib.request + + +def list_sections(config_file): + """List available configuration sections using taler-exchange-config.""" + cmd = ["taler-exchange-config"] + if config_file: + cmd.extend(["-c", config_file]) + cmd.append("-S") + try: + res = subprocess.run(cmd, capture_output=True, text=True, check=True) + lines = res.stdout.strip().split("\n") + sections = [] + for line in lines: + line = line.strip() + if line and not line.startswith("The following sections are available"): + sections.append(line) + return sections + except subprocess.CalledProcessError as e: + print(f"Warning: failed to list sections: {e.stderr.strip()}", file=sys.stderr) + return [] + + +def get_config_val(config_file, section, option): + """Retrieve a configuration value using taler-exchange-config.""" + cmd = ["taler-exchange-config"] + if config_file: + cmd.extend(["-c", config_file]) + cmd.extend(["-s", section, "-o", option]) + res = subprocess.run(cmd, capture_output=True, text=True) + if res.returncode == 0: + return res.stdout.strip() + return None + + +def resolve_section(config_file, section_arg): + """Resolve the section name by checking exact matches and kyc-provider prefixes/suffixes.""" + sections = list_sections(config_file) + if not sections: + return section_arg + + # Try exact match + if section_arg in sections: + return section_arg + + # Try prepending 'kyc-provider-' + prefixed = f"kyc-provider-{section_arg}" + if prefixed in sections: + return prefixed + + # Try suffixing with '-kyc-provider' + suffixed = f"{section_arg}-kyc-provider" + if suffixed in sections: + return suffixed + + # Try finding any section containing section_arg + matches = [s for s in sections if section_arg in s] + if len(matches) == 1: + return matches[0] + elif len(matches) > 1: + print(f"Warning: Ambiguous section name '{section_arg}'. Matches: {', '.join(matches)}", file=sys.stderr) + + return section_arg + + +def main(): + if not shutil.which("taler-exchange-config"): + print("Error: 'taler-exchange-config' command not found in PATH.", file=sys.stderr) + sys.exit(1) + + parser = argparse.ArgumentParser( + description="Initiate a Challenger address verification process using taler-exchange configuration." + ) + parser.add_argument( + "section", + help="The configuration section name (with or without 'kyc-provider-' prefix/suffix, e.g., 'postal-challenger')" + ) + parser.add_argument( + "-c", "--config", + help="Path to the taler-exchange configuration file (e.g., challenger-test.conf)" + ) + parser.add_argument( + "-r", "--redirect-uri", + help="OAuth2 redirect URI (falls back to exchange BASE_URL/kyc-proof/<provider_name>)" + ) + parser.add_argument( + "-s", "--state", + help="OAuth2 state parameter (default: cryptographically secure random string)" + ) + parser.add_argument( + "-o", "--read-only", + action="store_true", + help="Instruct Challenger to prevent editing of the pre-populated address" + ) + parser.add_argument( + "--json", + action="store_true", + help="Output results in JSON format" + ) + parser.add_argument( + "-v", "--verbose", + action="store_true", + help="Log verbose HTTP request/response details" + ) + parser.add_argument( + "--address-json", + help="Raw JSON object containing fields to pre-populate the address" + ) + + # Individual address fields for convenience + parser.add_argument("--email", help="Pre-populate CONTACT_EMAIL (for email validation)") + parser.add_argument("--phone", help="Pre-populate CONTACT_PHONE (for phone validation)") + parser.add_argument("--name", help="Pre-populate CONTACT_NAME (for postal validation)") + parser.add_argument("--address-lines", help="Pre-populate ADDRESS_LINES (for postal validation)") + parser.add_argument("--country", help="Pre-populate ADDRESS_COUNTRY (for postal validation)") + + args = parser.parse_args() + + # Configure logging + log_level = logging.DEBUG if args.verbose else logging.INFO + logging.basicConfig( + level=log_level, + format="%(asctime)s [%(levelname)s] %(message)s", + stream=sys.stderr + ) + + # Resolve section name + section = resolve_section(args.config, args.section) + + # Read required OAuth2 configurations + client_id = get_config_val(args.config, section, "KYC_OAUTH2_CLIENT_ID") + client_secret = get_config_val(args.config, section, "KYC_OAUTH2_CLIENT_SECRET") + authorize_url = get_config_val(args.config, section, "KYC_OAUTH2_AUTHORIZE_URL") + + missing = [] + if not client_id: + missing.append("KYC_OAUTH2_CLIENT_ID") + if not client_secret: + missing.append("KYC_OAUTH2_CLIENT_SECRET") + if not authorize_url: + missing.append("KYC_OAUTH2_AUTHORIZE_URL") + + if missing: + print(f"Error: Missing required options in config section [{section}]: {', '.join(missing)}", file=sys.stderr) + sys.exit(1) + + # Construct the Setup URL + # E.g. https://postal.challenger.stage.taler-ops.ch/authorize#setup -> https://postal.challenger.stage.taler-ops.ch/setup/1 + url_no_frag = authorize_url.split('#')[0] + if '/' not in url_no_frag: + print(f"Error: Invalid KYC_OAUTH2_AUTHORIZE_URL: {authorize_url}", file=sys.stderr) + sys.exit(1) + base_part = url_no_frag.rsplit('/', 1)[0] + setup_url = f"{base_part}/setup/{client_id}" + + # Determine redirect URI + redirect_uri = args.redirect_uri + if not redirect_uri: + exchange_base_url = get_config_val(args.config, "exchange", "BASE_URL") + if exchange_base_url: + provider_name = section + if provider_name.startswith("kyc-provider-"): + provider_name = provider_name[len("kyc-provider-"):] + if not exchange_base_url.endswith("/"): + exchange_base_url += "/" + redirect_uri = f"{exchange_base_url}kyc-proof/{provider_name}" + + # Determine state parameter + state = args.state if args.state else secrets.token_hex(16) + + # Build Challenger setup request payload + payload = {"read_only": args.read_only} + + if args.address_json: + try: + json_data = json.loads(args.address_json) + payload.update(json_data) + except json.JSONDecodeError as e: + print(f"Error: Invalid JSON payload in --address-json: {e}", file=sys.stderr) + sys.exit(1) + + if args.email: + payload["CONTACT_EMAIL"] = args.email + if args.phone: + payload["CONTACT_PHONE"] = args.phone + if args.name: + payload["CONTACT_NAME"] = args.name + if args.address_lines: + payload["ADDRESS_LINES"] = args.address_lines + if args.country: + payload["ADDRESS_COUNTRY"] = args.country + + # Optional scope configuration + scope = get_config_val(args.config, section, "KYC_OAUTH2_SCOPE") + + # Make the HTTP setup POST request to Challenger + req_data = json.dumps(payload).encode("utf-8") + req = urllib.request.Request( + setup_url, + data=req_data, + headers={ + "Authorization": f"Bearer {client_secret}", + "Content-Type": "application/json" + }, + method="POST" + ) + + logging.info(f"Making POST request to {setup_url}") + # Redact client secret in headers for safety + headers_logged = dict(req.headers) + if 'Authorization' in headers_logged: + headers_logged['Authorization'] = 'Bearer <redacted>' + logging.debug(f"Request Headers: {headers_logged}") + logging.debug(f"Request Body: {payload}") + + try: + with urllib.request.urlopen(req) as response: + resp_bytes = response.read() + logging.info(f"Received HTTP {response.status} from Challenger") + logging.debug(f"Response Headers: {dict(response.getheaders())}") + logging.debug(f"Response Body: {resp_bytes.decode('utf-8')}") + resp_json = json.loads(resp_bytes.decode("utf-8")) + except urllib.error.HTTPError as e: + err_bytes = e.read() + err_msg = err_bytes.decode("utf-8") + logging.error(f"HTTP setup request failed with status code {e.code}: {e.reason}") + logging.debug(f"Response Headers: {dict(e.headers)}") + logging.debug(f"Response Body: {err_msg}") + + print(f"Error: HTTP setup request failed with status code {e.code}: {e.reason}", file=sys.stderr) + try: + err_json = json.loads(err_msg) + print(json.dumps(err_json, indent=2), file=sys.stderr) + except json.JSONDecodeError: + if err_msg: + print(err_msg, file=sys.stderr) + sys.exit(1) + except urllib.error.URLError as e: + logging.error(f"Network connection failed: {e.reason}") + print(f"Error: Network connection failed to {setup_url}: {e.reason}", file=sys.stderr) + sys.exit(1) + + nonce = resp_json.get("nonce") + if not nonce: + print("Error: Challenger response did not contain a 'nonce'.", file=sys.stderr) + print(json.dumps(resp_json, indent=2), file=sys.stderr) + sys.exit(1) + + # Build redirect / authorization URL + auth_base = f"{url_no_frag}/{nonce}" if not url_no_frag.endswith("/") else f"{url_no_frag}{nonce}" + query_params = { + "response_type": "code", + "client_id": client_id, + "state": state + } + if redirect_uri: + query_params["redirect_uri"] = redirect_uri + if scope: + query_params["scope"] = scope + + encoded_params = urllib.parse.urlencode(query_params) + auth_url = f"{auth_base}?{encoded_params}" + + if args.json: + out = { + "setup_url": setup_url, + "nonce": nonce, + "client_id": client_id, + "redirect_uri": redirect_uri, + "state": state, + "scope": scope, + "authorization_url": auth_url + } + print(json.dumps(out, indent=2)) + else: + print("Successfully initiated address verification with Challenger!") + print(f"Challenger Setup URL: {setup_url}") + print(f"Returned Nonce: {nonce}") + print(f"Client ID: {client_id}") + print(f"Redirect URI: {redirect_uri if redirect_uri else 'Not specified'}") + print(f"State: {state}") + if scope: + print(f"Scope: {scope}") + print("\nTo complete address verification, open the following URL in your browser:") + print(auth_url) + + +if __name__ == "__main__": + main() diff --git a/roles/devtesting/tasks/main.yml b/roles/devtesting/tasks/main.yml @@ -28,6 +28,15 @@ group: root mode: "0755" + +- name: Place challenger devtesting helper + ansible.builtin.copy: + src: files/taler-devtesting-exchange-challenger-setup + dest: /usr/local/bin/taler-devtesting-exchange-challenger-setup + owner: root + group: root + mode: "0755" + - name: Grant sudo rights to devtesting user ansible.builtin.copy: src: etc/sudoers.d/devtesting