From: "Shan Shaji" <s.shaji@proxmox.com>
To: "Azharul Haque" <haque@azharul.com>, <pve-devel@lists.proxmox.com>
Subject: Re: [PATCH dart-api-client v2 2/2] fix #4281: access: add OpenID Connect auth-url/login helpers
Date: Thu, 20 Aug 2026 11:48:59 +0200 [thread overview]
Message-ID: <DKTODQ6KIVIB.135Q0E7N3Q6GS@proxmox.com> (raw)
In-Reply-To: <20260810144713.75806-3-haque@azharul.com>
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 <haque@azharul.com>
> ---
> 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<ProxmoxApiClient> 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<String> 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<ProxmoxApiClient> 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<List<PveAccessDomainModel?>> 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})::')
I know, you copied this regex from the handleAccessTicketResponse function. But IMHO,
the first group don't need to match for PMG as well. It could just be PVE.
final ticketRegex = RegExp(r'(PVE)(?: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();
> + }
This block is common in both handleAccessTicketResponse and handleOpenIdLogiResponse
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 =
> + '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<Credentials>()
> + .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)));
> + });
> });
> }
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.
next prev parent reply other threads:[~2026-08-20 9:49 UTC|newest]
Thread overview: 19+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-08-10 5:40 [PATCH 0/2] android: register OpenID Connect callback activity for #4281 Azharul Haque
2026-08-10 5:40 ` [PATCH 1/2] fix #4281: android: register OpenID Connect callback activity Azharul Haque
2026-08-10 5:40 ` [PATCH 2/2] fix #4281: android: match renamed OpenID callback scheme Azharul Haque
2026-08-10 13:59 ` [PATCH 0/2] android: register OpenID Connect callback activity for #4281 Shan Shaji
2026-08-10 14:47 ` [PATCH v2 0/7] app: implement OpenID Connect (OAuth) realm login (#4281) Azharul Haque
2026-08-10 14:47 ` [PATCH dart-api-client v2 1/2] fix #4281: access: add `type` property to `PveAccessDomainModel` Azharul Haque
2026-08-20 9:17 ` Shan Shaji
[not found] ` <CAFCWXbiQxG4p2U+beMjSi7Gaz5qC-vCzXW1AH3Ys-ApOW0S83w@mail.gmail.com>
2026-08-20 14:39 ` Shan Shaji
[not found] ` <CAFCWXbhnW0o1VcUimwk6pUB3VqCaQw31BQpuHjDtKF8LGeB-fg@mail.gmail.com>
2026-08-21 8:00 ` Shan Shaji
2026-08-10 14:47 ` [PATCH dart-api-client v2 2/2] fix #4281: access: add OpenID Connect auth-url/login helpers Azharul Haque
2026-08-20 9:48 ` Shan Shaji [this message]
2026-08-10 14:47 ` [PATCH login-manager v2 1/3] fix #4281: ui: add OpenID Connect login flow to login form Azharul Haque
2026-08-20 12:31 ` Shan Shaji
2026-08-10 14:47 ` [PATCH login-manager v2 2/3] fix #4281: ui: fix stale Continue button state on realm switch Azharul Haque
2026-08-10 14:47 ` [PATCH login-manager v2 3/3] fix #4281: ui: use a namespaced OpenID callback scheme Azharul Haque
2026-08-20 14:28 ` Shan Shaji
2026-08-10 14:47 ` [PATCH flutter-frontend v2 1/2] fix #4281: android: register OpenID Connect callback activity Azharul Haque
2026-08-20 14:01 ` Shan Shaji
2026-08-10 14:47 ` [PATCH flutter-frontend v2 2/2] fix #4281: android: match renamed OpenID callback scheme Azharul Haque
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=DKTODQ6KIVIB.135Q0E7N3Q6GS@proxmox.com \
--to=s.shaji@proxmox.com \
--cc=haque@azharul.com \
--cc=pve-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox