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 DE4BB1FF0B2 for ; Thu, 20 Aug 2026 11:49:17 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id E64402159A; Thu, 20 Aug 2026 11:49:16 +0200 (CEST) Mime-Version: 1.0 Content-Transfer-Encoding: quoted-printable Content-Type: text/plain; charset=UTF-8 Date: Thu, 20 Aug 2026 11:48:59 +0200 Message-Id: Subject: Re: [PATCH dart-api-client v2 2/2] fix #4281: access: add OpenID Connect auth-url/login helpers From: "Shan Shaji" To: "Azharul Haque" , X-Mailer: aerc 0.20.0 References: <20260810144713.75806-1-haque@azharul.com> <20260810144713.75806-3-haque@azharul.com> In-Reply-To: <20260810144713.75806-3-haque@azharul.com> X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1787219315016 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.462 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) 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_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: CG2YKHCYSIZ5DGO27Z6U5X27O4Q7ABIT X-Message-ID-Hash: CG2YKHCYSIZ5DGO27Z6U5X27O4Q7ABIT X-MailFrom: s.shaji@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: On Mon Aug 10, 2026 at 4:47 PM CEST, Azharul Haque wrote: > 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( > } > } > =20 > +/// Requests the provider's authorization URL for an OpenID Connect real= m. > +/// > +/// [redirectUrl] must match a redirect URI registered with the realm's > +/// OpenID provider, and is where the provider sends the user back to af= ter > +/// they authenticate (carrying `state` and `code` query parameters). > +Future openIdAuthUrl( > + String realm, > + Uri apiBaseUrl, > + Uri redirectUrl, > + bool validateSSL, { > + http.Client? httpClient, > +}) async { > + httpClient ??=3D getCustomIOHttpClient(validateSSL: validateSSL); > + > + var body =3D { > + 'realm': realm, > + 'redirect-url': redirectUrl.toString(), > + }; > + > + try { > + final path =3D '/api2/json/access/openid/auth-url'; > + final response =3D 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 =3D=3D -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 red= irect > +/// for a Proxmox VE ticket, mirroring what [authenticate] does for pass= word > +/// realms. > +Future openIdLogin( > + String state, > + String code, > + Uri apiBaseUrl, > + Uri redirectUrl, > + bool validateSSL, { > + http.Client? httpClient, > +}) async { > + httpClient ??=3D getCustomIOHttpClient(validateSSL: validateSSL); > + > + var body =3D { > + 'state': state, > + 'code': code, > + 'redirect-url': redirectUrl.toString(), > + }; > + > + try { > + final path =3D '/api2/json/access/openid/login'; > + final response =3D await httpClient > + .post(apiBaseUrl.replace(path: path), body: body) > + .timeout(Duration(seconds: 25)); > + > + final credentials =3D handleOpenIdLoginResponse(response, apiBaseUrl= ); > + > + return ProxmoxApiClient( > + credentials, > + httpClient: httpClient, > + ); > + } on NSErrorClientException catch (e) { > + if (e.error.code =3D=3D -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( > ); > } > =20 > +Credentials handleOpenIdLoginResponse( > + http.Response response, Uri apiBaseUrl) { > + response.validate(false); > + final bodyJson =3D jsonDecode(response.body)['data']; > + > + final ticket =3D bodyJson['ticket']; > + > + final csrfToken =3D bodyJson['CSRFPreventionToken']; > + > + final username =3D bodyJson['username']; > + > + final ticketRegex =3D RegExp(r'(PVE|PMG)(?:QUAR)?:(?:(\S+):)?([A-Z= 0-9]{8})::') I know, you copied this regex from the handleAccessTicketResponse funct= ion. But IMHO, the first group don't need to match for PMG as well. It could just be P= VE. final ticketRegex =3D RegExp(r'(PVE)(?:QUAR)?:(?:(\S+):)?([A-Z0-9]{8}= )::') > + .firstMatch(bodyJson['ticket'])!; > + > + final time =3D DateTime.fromMillisecondsSinceEpoch( > + int.parse(ticketRegex.group(3)!, radix: 16) * 1000); > + > + TfaChallenge? tfa; > + if (ticket.startsWith('PVE:!tfa!')) { > + tfa =3D TfaChallenge.fromJson( > + jsonDecode(Uri.decodeComponent(ticket.substring(9).split(':'= )[0]))); > + } else if (bodyJson['NeedTFA'] !=3D null && bodyJson['NeedTFA'] = =3D=3D 1) { > + tfa =3D TfaChallenge.legacy(); > + } This block is common in both handleAccessTicketResponse and handleOpenIdL= ogiResponse functions. IMHO, we could seperate this into its own function. > + 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 =3D > + 'PVE:jdoe@keycloak:5DF8EC22::STV4HNO1wplmsyMDM5s6SUsU4cS7sBBBw= +HOCEhSSV+6WGtz3zwIzHqBhq/ziJoBs7NqqyLXG4wn9jXJCMdYht+ndqwxtdFQsUNOF1Q/eTWw= cyl+Q1fmPNOIIUoxMY8OqGBVozgIimiAJxdqm+2SJnrPEmlJge6m3yf/OEVAkKFCfRMOtSuyVnI= buLx6h6obvezBUP5+ZHzeTMmmXcH4rOsOKgW9XfwryLHbkjjq9Ennx0xjQaBD9Bo5ERquY0hNmW= cdPC/p7ZzILTr4xH9sJe9Na2z6GhgJyTgOCAMengyIegySMq7IKIkmsp8odF4/iIC3005/XLF4w= /DjPYQUMA=3D=3D'; > + final csrfToken =3D '5DF8EDEC:/bb44xdHyVQDo2eD/8ty0WVXwMgwt1HjhVHL= ZX2YbxQ'; > + var response =3D http.Response( > + '{"data":{"clustername":"testcluster","username":"jdoe@keycloa= k","CSRFPreventionToken":"5DF8EDEC:/bb44xdHyVQDo2eD/8ty0WVXwMgwt1HjhVHLZX2Y= bxQ","cap":{},"ticket":"$ticket"}}', > + 200); > + expect( > + handleOpenIdLoginResponse(response, dummyEndpoint), > + isA() > + .having((e) =3D> e.username, 'Username', 'jdoe@keycloak') > + .having((e) =3D> e.ticket, 'Ticket', ticket) > + .having((e) =3D> e.csrfToken, 'CSRF Token', csrfToken) > + .having( > + (e) =3D> e.expiration, > + 'Token expiration time', > + DateTime.fromMillisecondsSinceEpoch( > + int.parse('5DF8EC22', radix: 16) * 1000))); > + }); > }); > } If you would like to make the changes I mentioned, please feel free to do that, else I can do it in a seperate series.=20