From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [IPv6:2a0f:8001:1:32::40]) by lore.proxmox.com (Postfix) with ESMTPS id 561641FF0E1 for ; Mon, 10 Aug 2026 14:36:28 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 9C222216D0; Mon, 10 Aug 2026 14:36:16 +0200 (CEST) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=azharul.com; s=t28hkp5; t=1786340291; x=1786945091; darn=lists.proxmox.com; h=content-transfer-encoding:mime-version:references:in-reply-to :message-id:date:subject:cc:to:from:from:to:cc:subject:date :message-id:reply-to:content-type; bh=ZdmwciNaWgvmYa1f3TRSuSjC1n/EBsREYzoEUMmgugQ=; b=S1/SMS3thiEjMIVNmzdki3+jVB1V8M0WvtXnHKhjPZg6f2RctCuJIaKKUkA5YV/bR5 x+zmoFChcxZUPNqO3DsT0mskznPTOW7FUMzZgVEEWLhA850D1629VYTRd8yGpI2sRWp/ x6+r+YIXNnEXgB2eRhI1QeezJapMnQ/ZurBhQ8I9gqe4+HJ999m1DhQzFZ2G0N/5r8OJ QmYAgF6cVbjkIfU30cKdShGqpBg6x8L+UVT9uH6Zu6qamQqhcmQwoA2HldybEX+kkHFR sf6HUf1VL8kV7Xbf36w7x1O8Gwv1s4XgYdEdKMpCEebUa2AO/wiG+akKz3OWoGxbycz7 DL9w== X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20251104; t=1786340291; x=1786945091; h=content-transfer-encoding:mime-version:references:in-reply-to :message-id:date:subject:cc:to:from:x-gm-gg:x-gm-message-state:from :to:cc:subject:date:message-id:reply-to:content-type; bh=ZdmwciNaWgvmYa1f3TRSuSjC1n/EBsREYzoEUMmgugQ=; b=I4nSANnrpTcaYVLCMPYZ9WOoabMKcaiEud0sexkuo1OkXWgPB42gS+7JymaK+eZt6c FRLABmWPTVg++yChZPWal7c3Ty+3mOT43cUX04LIQNXJyDbvBciIa3RV7d814K3Uz8wX oRKE7oPAXY/Dqxj5cc3lc+s4LceuKD2e3rKbljML6k679YTNX8DkhZ7mM0Lrmny7EZiO zY/0WHGKp82rIajRWnozmZlhi0FEvIZLrvhZJ90Bv8lXCtstDu6MkCfixdzgvBYiW7Zv kHp6Nm4Jxxiho7OcOTPcAMp3synMYUSyBGhZ6Oj1jYmUxLyBwzRkVZ9QXHmY/+IhgXFM WFWw== X-Gm-Message-State: AOJu0YwGAVHCghVZfJCHcYYobT11kXAoiteVdPV1vIg67Mup27JJD/Iu cvSq91ZoruvMxm5w7dyBD+uXioRtCuEvMpfXyVSNqZhMGyqpVJEhNYhceh496HEiMbiTsFqTxZn qJdargw== X-Gm-Gg: AR+sD10HgQc7ARVD+suMNq1n7IEiirnvJm+Rv61C0j7wH38xgZI7WQZ/OZ5TXBjUNac GXIwS4Q03NEUZoT9PZFKp81hiIj3y9ivd7q2bsj7kDppfb7LjsG7IUaaIfySgczeoBUG3I4hRu7 DVX3Y/lEUuggv0LQqE84IMuIzN6jWMJUPPgxmCKrbva3FS9KK4NaEuhyPwjaNJ1wvTdV0TWz19q goKSef7D2ciQfjjAGf+16TnY2gZUWcbRcWU0aD9PwxhWhLVGz+y8hECkMm31rIzQhwEBLlEmtZB AS26TS4h1uz+aVIc1YXP3KBEySubXVSX/jigQgaGauRDQBDUTmTb2fnCSgdQULL5Znx2Zoyn6JY WXOwNm2C6ol/AGi0wcQ7fDlFLNjm4C6hNoqTHzQNiftVa6mj+TXUqEfpjaoJ5xMREwZ9jQfGu3H dNs8ihhqaZbITt91PgUcnc9WgokQpmb4MVPexjss9qS/GkUKRvwVq+TAQkuC+by+q9OR2lHDo05 VPyy4ZfjuHLa3da2jkVHgGYw7Nhkn+bnlxqHcxeYnvCdCferN2R4h1qAB7KpUkRu2ZlyGzO7kSp ltR8uUHj9JYb9JuhaieYJFkbQbLNgHaVTPFQiNQK12ESD26C0bDl9RHeKpOIQTOPtG9oI0rFSb+ jOmOsLxApSpEsRfTfqrguIn9guQQ= X-Received: by 2002:a05:620a:2cc6:b0:92e:f3ca:2ebc with SMTP id af79cd13be357-9364927a0efmr2994176885a.37.1786340291074; Sun, 09 Aug 2026 22:38:11 -0700 (PDT) From: Azharul Haque To: pve-devel@lists.proxmox.com Subject: [PATCH 2/2] fix #4281: access: add OpenID Connect auth-url/login helpers Date: Mon, 10 Aug 2026 01:37:32 -0400 Message-ID: <20260810053732.16627-3-haque@azharul.com> X-Mailer: git-send-email 2.50.1 In-Reply-To: <20260810053732.16627-1-haque@azharul.com> References: <20260810053732.16627-1-haque@azharul.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL 0.300 Adjusted score from AWL reputation of From: address DKIM_SIGNED 0.1 Message has a DKIM or DK signature, not necessarily valid DKIM_VALID -0.1 Message has at least one valid DKIM or DK signature DKIM_VALID_AU -0.1 Message has a valid DKIM or DK signature from author's domain DKIM_VALID_EF -0.1 Message has a valid DKIM or DK signature from envelope-from domain DMARC_PASS -0.1 DMARC pass policy POISEN_SPAM_PILL 0.1 Meta: its spam POISEN_SPAM_PILL_1 0.1 random spam to be learned in bayes POISEN_SPAM_PILL_3 0.1 random spam to be learned in bayes RCVD_IN_DNSWL_NONE -0.0001 Sender listed at https://www.dnswl.org/, no trust SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record X-MailFrom: haque@azharul.com X-Mailman-Rule-Hits: nonmember-moderation X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation Message-ID-Hash: Q4UH3W3Q66ULBRIQVEXN3CWWLGWXKPQS X-Message-ID-Hash: Q4UH3W3Q66ULBRIQVEXN3CWWLGWXKPQS X-Mailman-Approved-At: Mon, 10 Aug 2026 14:36:15 +0200 CC: haque@azharul.com X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: Add openIdAuthUrl() and openIdLogin(), mirroring the existing authenticate()/accessDomains() functions used by the login form before an authenticated ProxmoxApiClient exists. openIdAuthUrl() requests the provider's authorization URL for a realm from /access/openid/auth-url. openIdLogin() exchanges the state/code obtained from the provider's redirect for a PVE ticket via /access/openid/login, the same way authenticate() does for password realms. The OpenID login response carries the authenticated username in its body rather than it being known upfront by the caller, so handleOpenIdLoginResponse() is added alongside the existing handleAccessTicketResponse()/handleTfaChallengeResponse() to build Credentials from it. Signed-off-by: Azharul Haque --- lib/src/authenticate.dart | 85 +++++++++++++++++++++++++++++ lib/src/handle_ticket_response.dart | 36 ++++++++++++ test/test.dart | 20 +++++++ 3 files changed, 141 insertions(+) diff --git a/lib/src/authenticate.dart b/lib/src/authenticate.dart index 7bd9cef..e2d76a1 100644 --- a/lib/src/authenticate.dart +++ b/lib/src/authenticate.dart @@ -72,6 +72,91 @@ Future authenticate( } } +/// Requests the provider's authorization URL for an OpenID Connect realm. +/// +/// [redirectUrl] must match a redirect URI registered with the realm's +/// OpenID provider, and is where the provider sends the user back to after +/// they authenticate (carrying `state` and `code` query parameters). +Future openIdAuthUrl( + String realm, + Uri apiBaseUrl, + Uri redirectUrl, + bool validateSSL, { + http.Client? httpClient, +}) async { + httpClient ??= getCustomIOHttpClient(validateSSL: validateSSL); + + var body = { + 'realm': realm, + 'redirect-url': redirectUrl.toString(), + }; + + try { + final path = '/api2/json/access/openid/auth-url'; + final response = await httpClient + .post(apiBaseUrl.replace(path: path), body: body) + .timeout(Duration(seconds: 25)); + + response.validate(true); + + return jsonDecode(response.body)['data'] as String; + } on NSErrorClientException catch (e) { + if (e.error.code == -1202) { + throw HandshakeException(e.message); + } + rethrow; + } on http.ClientException catch (e) { + if (e.message.contains('net::ERR_CERT_AUTHORITY_INVALID')) { + throw HandshakeException(e.message); + } + rethrow; + } +} + +/// Exchanges the `state`/`code` obtained from the OpenID provider's redirect +/// for a Proxmox VE ticket, mirroring what [authenticate] does for password +/// realms. +Future openIdLogin( + String state, + String code, + Uri apiBaseUrl, + Uri redirectUrl, + bool validateSSL, { + http.Client? httpClient, +}) async { + httpClient ??= getCustomIOHttpClient(validateSSL: validateSSL); + + var body = { + 'state': state, + 'code': code, + 'redirect-url': redirectUrl.toString(), + }; + + try { + final path = '/api2/json/access/openid/login'; + final response = await httpClient + .post(apiBaseUrl.replace(path: path), body: body) + .timeout(Duration(seconds: 25)); + + final credentials = handleOpenIdLoginResponse(response, apiBaseUrl); + + return ProxmoxApiClient( + credentials, + httpClient: httpClient, + ); + } on NSErrorClientException catch (e) { + if (e.error.code == -1202) { + throw HandshakeException(e.message); + } + rethrow; + } on http.ClientException catch (e) { + if (e.message.contains('net::ERR_CERT_AUTHORITY_INVALID')) { + throw HandshakeException(e.message); + } + rethrow; + } +} + Future> accessDomains( Uri apiBaseUrl, bool validateSSL, { diff --git a/lib/src/handle_ticket_response.dart b/lib/src/handle_ticket_response.dart index ba2128f..a43aed0 100644 --- a/lib/src/handle_ticket_response.dart +++ b/lib/src/handle_ticket_response.dart @@ -39,6 +39,42 @@ Credentials handleAccessTicketResponse( ); } +Credentials handleOpenIdLoginResponse( + http.Response response, Uri apiBaseUrl) { + response.validate(false); + + final bodyJson = jsonDecode(response.body)['data']; + + final ticket = bodyJson['ticket']; + + final csrfToken = bodyJson['CSRFPreventionToken']; + + final username = bodyJson['username']; + + final ticketRegex = RegExp(r'(PVE|PMG)(?:QUAR)?:(?:(\S+):)?([A-Z0-9]{8})::') + .firstMatch(bodyJson['ticket'])!; + + final time = DateTime.fromMillisecondsSinceEpoch( + int.parse(ticketRegex.group(3)!, radix: 16) * 1000); + + TfaChallenge? tfa; + if (ticket.startsWith('PVE:!tfa!')) { + tfa = TfaChallenge.fromJson( + jsonDecode(Uri.decodeComponent(ticket.substring(9).split(':')[0]))); + } else if (bodyJson['NeedTFA'] != null && bodyJson['NeedTFA'] == 1) { + tfa = TfaChallenge.legacy(); + } + + return Credentials( + apiBaseUrl, + username, + ticket: ticket, + csrfToken: csrfToken, + expiration: time, + tfa: tfa, + ); +} + Credentials handleTfaChallengeResponse( http.Response response, Credentials pendingTfaCredentials) { response.validate(false); diff --git a/test/test.dart b/test/test.dart index 23368a2..86c2272 100644 --- a/test/test.dart +++ b/test/test.dart @@ -49,5 +49,25 @@ void main() { DateTime.fromMillisecondsSinceEpoch( int.parse('5DF8EC22', radix: 16) * 1000))); }); + + test('valid openid login response extraction', () { + final ticket = + 'PVE:jdoe@keycloak:5DF8EC22::STV4HNO1wplmsyMDM5s6SUsU4cS7sBBBw+HOCEhSSV+6WGtz3zwIzHqBhq/ziJoBs7NqqyLXG4wn9jXJCMdYht+ndqwxtdFQsUNOF1Q/eTWwcyl+Q1fmPNOIIUoxMY8OqGBVozgIimiAJxdqm+2SJnrPEmlJge6m3yf/OEVAkKFCfRMOtSuyVnIbuLx6h6obvezBUP5+ZHzeTMmmXcH4rOsOKgW9XfwryLHbkjjq9Ennx0xjQaBD9Bo5ERquY0hNmWcdPC/p7ZzILTr4xH9sJe9Na2z6GhgJyTgOCAMengyIegySMq7IKIkmsp8odF4/iIC3005/XLF4w/DjPYQUMA=='; + final csrfToken = '5DF8EDEC:/bb44xdHyVQDo2eD/8ty0WVXwMgwt1HjhVHLZX2YbxQ'; + var response = http.Response( + '{"data":{"clustername":"testcluster","username":"jdoe@keycloak","CSRFPreventionToken":"5DF8EDEC:/bb44xdHyVQDo2eD/8ty0WVXwMgwt1HjhVHLZX2YbxQ","cap":{},"ticket":"$ticket"}}', + 200); + expect( + handleOpenIdLoginResponse(response, dummyEndpoint), + isA() + .having((e) => e.username, 'Username', 'jdoe@keycloak') + .having((e) => e.ticket, 'Ticket', ticket) + .having((e) => e.csrfToken, 'CSRF Token', csrfToken) + .having( + (e) => e.expiration, + 'Token expiration time', + DateTime.fromMillisecondsSinceEpoch( + int.parse('5DF8EC22', radix: 16) * 1000))); + }); }); } -- 2.50.1 (Apple Git-155)