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 266D91FF0EA for ; Thu, 13 Aug 2026 17:58:50 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 1AB35219B6; Thu, 13 Aug 2026 17:58:41 +0200 (CEST) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=azharul.com; s=t28hkp5; t=1786373264; x=1786978064; 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=YdfBhAwIeAXyyrNsGoHDGSbwaxeBmm331hYFI3t6X/bwdBVTWCHyZiu5FQvP2Qm9gc iGs2QRD35EkCjDGi0fOK3fA30JI0l1NhQeyYl1tVhFHsmry3IA7EsM1jbWvWMqhfFmt5 OgTC/G90lpnT6VEfuli+Tc7mCMN0FqhaNiB/960VGd1BsfLi9lrxiTzotw56yTo0dK4+ 4JH3/AGW4BiqmZVj4alujdmXoUaivt5tlohaCfyRLLjBYj1123gAzxe+YXEjZtar+oFX uGchmhhLwYlnlublanFn/2XOx8giibcmxajAsQ56oaquT38J1pnpyNDRYNpBW4DNOdps uk7Q== X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20251104; t=1786373264; x=1786978064; 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=rHIKD+5rW02mrOiE1OzJpbUwCrIeVuqFLW07Q8Y/quxM0U+jGA2lYprQtc/6hULmrv nj50+y5NrEoV5rPASiYlhhjpR2k97/A7K+vMM+QEPAlK3Rzmc+Kvc4FaSJ1QeEfjnWMx 1A4yjQlY7vdYGdAXdglLb+4AllvuEGEtYG7qV97aBxQx8PJPkzRN2KXjw+D5aXYjPDWw 5GGbloaocgMyp2sA8ZUMU4ySMvKFwASVnMIV4rBvvTjtYnKoHpLvBfaaXjyjFatKCFtf UmfKUjj5TDcdy+xQgnn0vQZz0H5TsOP/h6ke8J9j5e0NArcCDFFsrQsFKw4UXmZQ3PZP WL8g== X-Gm-Message-State: AOJu0Yx/7vs16e9KOpFv9BtY68tv+26pLRb8V+5QTHNe/3xKsSHwYUcP PDpaeFZxzcsXNU0xt5ZCZEKmF3aixOMnbYnWa+lMVgwl+xDrADmQqsQKoiixffYCmqVlj+dLdo+ go+PLYA== X-Gm-Gg: AR+sD13rW6gTuIyrqEMgwq+19xo31hSS7xNytYHu/gFr3w2nEZ2XPr0d9WL2e+w/xC2 1+stvXqB1GsaXIm8ty+DFIU5QjiV7XAdgwJgB+1M4ccJjk8mvy6pm7M8r1HsfzptXQt87MQIKJC /be3D1XSfATb/TQiLuOiQh81KH2IF8tZtCvdKAUfMOmh+kdEjc8Tr68Mcw3tmXVKiQwPPHA0MV0 IBzGhpR9CADa+9Z95Ol9iZ9dPtQWwa19kzXQq7jsJHoY2HP6Voz5dnneB2W29KNPki3Qs5DeSl3 KVeu7gOvnRaBT47E7uhMu1KO/pj5BUvpHO/cQWJ4ZCEsBPz3RMofaPFWbURRWM0pAX9gZ70dl3n r3chAbrSMZ1j9MhjbsqwkIbBTQF0YLTNHSdwdsZmfuca7eG9N3iVCxfrJMErX/37x1XiIhApBGu 0lF3Us+G9SybhsxBsDnAyAdr6JIqGTJmqjAtGbSHLUawTx7YfPMXMQsE1jSscatanRWd/gwP7Ok VER6hOHYaFqey9YA4iyCKNEYRXyfjX/BYD79FlIlkOUQW2d+ybh5fKUSIWLg9fyZrcjSoisRwWk gaUxTHpZSqUOYCIvcWHEmffEd8lsRUIwDo1Am602bvHuGNBQWIo4HjtQzLlEVImMY37jdL0D5rL C3X+Myoz1QpnQOxmmKnTF3jO1dkBqfp8ay/O4+Q== X-Received: by 2002:a05:620a:6507:b0:936:41fe:2351 with SMTP id af79cd13be357-93671eae1admr2567537485a.30.1786373264031; Mon, 10 Aug 2026 07:47:44 -0700 (PDT) From: Azharul Haque To: pve-devel@lists.proxmox.com Subject: [PATCH dart-api-client v2 2/2] fix #4281: access: add OpenID Connect auth-url/login helpers Date: Mon, 10 Aug 2026 10:47:08 -0400 Message-ID: <20260810144713.75806-3-haque@azharul.com> X-Mailer: git-send-email 2.50.1 In-Reply-To: <20260810144713.75806-1-haque@azharul.com> References: <20260810144713.75806-1-haque@azharul.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL 0.033 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: ODOJWU4JH5P6AYUWR5GSLWQKXZFASKQB X-Message-ID-Hash: ODOJWU4JH5P6AYUWR5GSLWQKXZFASKQB X-Mailman-Approved-At: Thu, 13 Aug 2026 17:58:30 +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)