From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [45.144.208.40]) by lore.proxmox.com (Postfix) with ESMTPS id 42C1B1FF0C1 for ; Tue, 25 Aug 2026 10:09:34 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 95F0C21694; Tue, 25 Aug 2026 10:09:00 +0200 (CEST) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=azharul.com; s=t28hkp5; t=1787283733; x=1787888533; 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=h4esj0kxLJOoyVdAmZeNyurJ12b59FS6gV1ITv584Cc=; b=AAqf/r0UDTfXIfq2e8qD1gZuE8rEKhikTJQBHMeqkjBaDIt7DdazXx1wgODB9HFeri U/A2MyQu+dR+pxrJJlP5YwlTjT2LSkEsHw2ZrX08qfh6XaE5frmU7wI1/bKPdGJaWRQL wd6+nueQIpJ5t9Ulh+uj6Cpw36GyqZ9ieVN/SDCMBahN5N7TVd/Bksp5K1i3VGw8Zb/r 4OUps+U6ZlZuXCkf48PMA+OYLglyin+x6U/OFPoj5ga3KqeYax4z8B0/RP6wjo53hpOQ J3j5npplCilCQcPs/tIPMDElH3rqzsStavAjPWT7pS8MQoQCIuZR2UR5EDJZU7UXOyry rU3A== X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20251104; t=1787283733; x=1787888533; 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=h4esj0kxLJOoyVdAmZeNyurJ12b59FS6gV1ITv584Cc=; b=WWTfT9V7xcaMvKl/SVXsqfFE303rS0gOzy8EttQtsUSXj3yB35IRnv77pG2/6YSzQc xn4/3dbXLJ1e02N+sW7jbTub98LFWv+wxj4DmmyoZDLoObyJBseRZqNCurbGelHGIWOI 9i7kom/fzcSkILK/jRT103NU7AxIJM6ayZLRnCI4mKBBkvaLkTRQrbQOBeXYTbzovCnF Jd3UyHanMwffaEX+GRqKJyTsCzTzeNw18EzkjI1GxaahvsVNi64gt93CwzbVw0CtgM/O TSoqDYFyjt8Mv1UBVXkUnnJh3tOBoGiYwcYUuHHl2Bw3nM5xlu3G1LsBwzEDzbhq8vhy +ufA== X-Gm-Message-State: AOJu0YxvEqtL5Whxn4b99cTLZb2XQ+uVDPMohziaXQnUADe3bCbsPlt8 kNI60lHZyAkkg67akWmrmrbUZQRUTTNMxc9gvGYdB/0O9+G+yrgscqtRcDOaxJw+SCmvVM68ohQ K/v6zXg== X-Gm-Gg: AR+sD11ujcu3rsfVvEL/nfuvCpxE8hKeVMhzX2KqMEU0vjC2KFtTy3upSNfIuJ+gSU5 N6IYbR49LFx8Eqyhvq4u8PIE/0/4Mr412BwZozVA3jrr0EepzGVrUQi8yooaSY0moqN3QwAOcmo ZOa8kTAYhbWtktZa29P4+/VX23UDSbVd0z9sjzi/zj5V8OONJhwxxxQ3E5EEwI9N6uYxxxqgoj8 oP7iUxPN2rDsBO7l0SrtMr/i8k8aVYN7rengnYXSD7Leg5B3Mnq18gP5MkOqwsK/QZ86H8W7A7S yrpYZrAMNu+D0U+E2bdowwcAo2KxUc9W8hHBc8yXpEI5mRQpwoEddHHpREN6Exh6PW9Kg4AHLLW 83BesgjXKieERHIjGEYPUo5iobZNbCO6K7zh7Kq5ERIPHSuU/5K3CeOUAbukmFmyKLl0DqpXV/V m+/ulhSmCpQeJfk5q6+tgxa7zwc0fyGItJZ+7pcKoAMFNsi+TxZgOYT+6n944MjWQo+fTFnXPgN 2bk6xljQBLlklEDzumo/KDfl6NURnngDLbJJMNLb0uIg44gizylYbG3/qzLzpgsLq9nNYsvNVWV kGVwyGGBjfA7Oj1I54v78VWzgd1d6GDVaB5wGyJY+4qTMEPZ+25M X-Received: by 2002:a05:620a:6d04:b0:930:db8a:c608 with SMTP id af79cd13be357-9373946becfmr253625285a.7.1787283733223; Thu, 20 Aug 2026 20:42:13 -0700 (PDT) From: Azharul Haque To: pve-devel@lists.proxmox.com Subject: [PATCH dart-api-client v3 2/2] fix #4281: access: add OpenID Connect auth-url/login helpers Date: Thu, 20 Aug 2026 23:41:39 -0400 Message-ID: <20260821034147.30194-3-haque@azharul.com> X-Mailer: git-send-email 2.50.1 In-Reply-To: <20260821034147.30194-1-haque@azharul.com> References: <20260810144713.75806-1-haque@azharul.com> <20260821034147.30194-1-haque@azharul.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL 0.600 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: 4RBHVDXBX3CIFJMLVQQPMDYSS63NDUCP X-Message-ID-Hash: 4RBHVDXBX3CIFJMLVQQPMDYSS63NDUCP X-Mailman-Approved-At: Tue, 25 Aug 2026 10:08:31 +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() to build Credentials from it. Its ticket regex only matches PVE tickets (not PMG), since OpenID login is only ever performed against a PVE realm; because the two regexes therefore differ, the regex match stays in each handler while the shared tail (deriving the expiration time and detecting an accompanying TFA challenge) is factored into a common helper used by both. Suggested-by: Shan Shaji Signed-off-by: Azharul Haque --- lib/src/authenticate.dart | 85 +++++++++++++++++++++++++++++ lib/src/handle_ticket_response.dart | 70 +++++++++++++++++++----- test/test.dart | 20 +++++++ 3 files changed, 161 insertions(+), 14 deletions(-) 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..f4bffde 100644 --- a/lib/src/handle_ticket_response.dart +++ b/lib/src/handle_ticket_response.dart @@ -5,6 +5,29 @@ import 'package:proxmox_dart_api_client/src/credentials.dart'; import 'package:proxmox_dart_api_client/src/extentions.dart'; import 'package:proxmox_dart_api_client/src/tfa_challenge.dart'; +/// Shared tail of parsing a ticket response: derives the ticket's +/// expiration time from its embedded timestamp, and determines whether a +/// TFA challenge accompanies it (either encoded in the ticket itself, or +/// flagged via the legacy `NeedTFA` field). +({DateTime expiration, TfaChallenge? tfa}) _parseTicketExpirationAndTfa( + RegExpMatch ticketMatch, + String ticket, + Map bodyJson, +) { + final expiration = DateTime.fromMillisecondsSinceEpoch( + int.parse(ticketMatch.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 (expiration: expiration, tfa: tfa); +} + Credentials handleAccessTicketResponse( http.Response response, Credentials unauthenticatedCredentials) { response.validate(false); @@ -15,27 +38,46 @@ Credentials handleAccessTicketResponse( final csrfToken = bodyJson['CSRFPreventionToken']; - final ticketRegex = RegExp(r'(PVE|PMG)(?:QUAR)?:(?:(\S+):)?([A-Z0-9]{8})::') - .firstMatch(bodyJson['ticket'])!; + final ticketMatch = RegExp(r'(PVE|PMG)(?:QUAR)?:(?:(\S+):)?([A-Z0-9]{8})::') + .firstMatch(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(); - } + final parsed = _parseTicketExpirationAndTfa(ticketMatch, ticket, bodyJson); return Credentials( unauthenticatedCredentials.apiBaseUrl, unauthenticatedCredentials.username, ticket: ticket, csrfToken: csrfToken, - expiration: time, - tfa: tfa, + expiration: parsed.expiration, + tfa: parsed.tfa, + ); +} + +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']; + + // OpenID Connect login is only ever performed against a PVE realm, so + // unlike handleAccessTicketResponse's regex, PMG never applies here. + final ticketMatch = + RegExp(r'(PVE)(?:QUAR)?:(?:(\S+):)?([A-Z0-9]{8})::').firstMatch(ticket)!; + + final parsed = _parseTicketExpirationAndTfa(ticketMatch, ticket, bodyJson); + + return Credentials( + apiBaseUrl, + username, + ticket: ticket, + csrfToken: csrfToken, + expiration: parsed.expiration, + tfa: parsed.tfa, ); } 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)