From a75af589fbc9a89776c7234840bb0db3ae360415 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 25 Aug 2026 11:02:30 -0500 Subject: [PATCH 1/2] feat(oauth2provider): let clients send resource indicators to /authorize BLOCKED until xraph/go-utils#4 ships and go.mod picks it up. Three tests in plugins/oauth2provider fail against go-utils v1.1.7, all of them downstream of the repeated query parameter that PR fixes. The authorization endpoint has honoured a repeatable `resource` all along, but read it off the raw request, so forge never saw a field to describe and the parameter reached no generated client. RFC 8707 worked there over curl and nowhere else. It is a `[]string` with a query tag now, resourceParams is gone, and the parameter is in the spec. Exposing it turned up the same defect one layer over. The query-string builders had never met an array parameter, because until now there was not one: Go q.Set("resource", fmt.Sprint(params.Resource)) preceded by a zero-value comparison that does not compile for a slice, so the generated SDK failed to build TypeScript params.set('resource', String(resource)) which joins on commas and sends one value Dart a Map, which cannot hold a repeated key at all, so only the last value would survive All three now write one entry per element, the same treatment the form encoders got. Dart builds a list of pairs rather than a map, since the map was the thing that made repetition impossible. The Go breakage is worth noting: a generated SDK that does not compile also blocks the generator, because dump-spec builds the module the SDK lives in. Recovering meant restoring sdk/go before regenerating. Wire-form tests cover one element and two on the query path in both TypeScript and Dart, matching the form-body tests already there. --- .../lib/src/generated/api_client.dart | 445 +++++++++++------- plugins/oauth2provider/plugin.go | 14 +- plugins/oauth2provider/resource.go | 24 - sdk/dart/lib/src/client.dart | 445 +++++++++++------- sdk/dart/test/form_encoding_test.dart | 56 +++ sdk/go/client.go | 21 +- sdk/typescript/src/client.ts | 6 +- sdk/typescript/test/form-encoding.test.ts | 54 +++ sdkgen/cmd/specgen/resource_parameter_test.go | 16 +- sdkgen/dart/generator.go | 6 + sdkgen/dart/templates/client.dart.tmpl | 25 +- sdkgen/golang/templates/client.go.tmpl | 7 + sdkgen/spec.json | 10 + sdkgen/typescript/generator.go | 6 + sdkgen/typescript/templates/client.ts.tmpl | 7 + ui/packages/core/src/generated/api-client.ts | 6 +- 16 files changed, 749 insertions(+), 399 deletions(-) diff --git a/flutter/packages/authsome_core/lib/src/generated/api_client.dart b/flutter/packages/authsome_core/lib/src/generated/api_client.dart index a699c739..6d8a72bf 100644 --- a/flutter/packages/authsome_core/lib/src/generated/api_client.dart +++ b/flutter/packages/authsome_core/lib/src/generated/api_client.dart @@ -384,10 +384,13 @@ class AuthClient { /// DELETE /v1/admin/bulk/sessions Future adminBulkRevokeSessions({required String userId, required String token}) async { final path = '/v1/admin/bulk/sessions'; - final queryParams = {}; - queryParams['user_id'] = userId.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('user_id', userId.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'DELETE', @@ -438,10 +441,13 @@ class AuthClient { /// GET /v1/admin/oauth/clients Future listOAuth2Clients({required String appId, required String token}) async { final path = '/v1/admin/oauth/clients'; - final queryParams = {}; - queryParams['app_id'] = appId.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('app_id', appId.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -479,10 +485,13 @@ class AuthClient { /// GET /v1/admin/orgs Future adminListOrgs({required String appId, required String token}) async { final path = '/v1/admin/orgs'; - final queryParams = {}; - queryParams['app_id'] = appId.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('app_id', appId.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -521,12 +530,15 @@ class AuthClient { /// GET /v1/admin/service-accounts Future adminListServiceAccounts({required String appId, required String token, int? limit, String? cursor}) async { final path = '/v1/admin/service-accounts'; - final queryParams = {}; - queryParams['app_id'] = appId.toString(); - if (limit != null) queryParams['limit'] = limit.toString(); - if (cursor != null) queryParams['cursor'] = cursor.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('app_id', appId.toString())); + if (limit != null) queryPairs.add(MapEntry('limit', limit.toString())); + if (cursor != null) queryPairs.add(MapEntry('cursor', cursor.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -590,11 +602,14 @@ class AuthClient { /// GET /v1/admin/settings/definitions Future listSettingsDefinitions({required String namespace, required String category, required String token}) async { final path = '/v1/admin/settings/definitions'; - final queryParams = {}; - queryParams['namespace'] = namespace.toString(); - queryParams['category'] = category.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('namespace', namespace.toString())); + queryPairs.add(MapEntry('category', category.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -633,11 +648,14 @@ class AuthClient { /// DELETE /v1/admin/settings/enforce/{key} Future unenforceSetting({required String key, required String scope, required String scopeId, required String token}) async { final path = '/v1/admin/settings/enforce/$key'; - final queryParams = {}; - queryParams['scope'] = scope.toString(); - queryParams['scope_id'] = scopeId.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('scope', scope.toString())); + queryPairs.add(MapEntry('scope_id', scopeId.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'DELETE', @@ -651,13 +669,16 @@ class AuthClient { /// GET /v1/admin/settings/resolve Future resolveSettings({required String namespace, required String appId, required String orgId, required String userId, required String token}) async { final path = '/v1/admin/settings/resolve'; - final queryParams = {}; - queryParams['namespace'] = namespace.toString(); - queryParams['app_id'] = appId.toString(); - queryParams['org_id'] = orgId.toString(); - queryParams['user_id'] = userId.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('namespace', namespace.toString())); + queryPairs.add(MapEntry('app_id', appId.toString())); + queryPairs.add(MapEntry('org_id', orgId.toString())); + queryPairs.add(MapEntry('user_id', userId.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -671,12 +692,15 @@ class AuthClient { /// GET /v1/admin/settings/resolve/{key} Future resolveSetting({required String key, required String appId, required String orgId, required String userId, required String token}) async { final path = '/v1/admin/settings/resolve/$key'; - final queryParams = {}; - queryParams['app_id'] = appId.toString(); - queryParams['org_id'] = orgId.toString(); - queryParams['user_id'] = userId.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('app_id', appId.toString())); + queryPairs.add(MapEntry('org_id', orgId.toString())); + queryPairs.add(MapEntry('user_id', userId.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -703,11 +727,14 @@ class AuthClient { /// DELETE /v1/admin/settings/values/{key} Future deleteSetting({required String key, required String scope, required String scopeId, required String token}) async { final path = '/v1/admin/settings/values/$key'; - final queryParams = {}; - queryParams['scope'] = scope.toString(); - queryParams['scope_id'] = scopeId.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('scope', scope.toString())); + queryPairs.add(MapEntry('scope_id', scopeId.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'DELETE', @@ -721,10 +748,13 @@ class AuthClient { /// GET /v1/admin/social/providers Future socialAdminListProviders({required String appId, required String token}) async { final path = '/v1/admin/social/providers'; - final queryParams = {}; - queryParams['app_id'] = appId.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('app_id', appId.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -750,10 +780,13 @@ class AuthClient { /// PUT /v1/admin/social/providers/{provider} Future socialAdminUpsertProvider({required String provider, required AdminUpsertProviderRequest body, required String appId, required String token}) async { final path = '/v1/admin/social/providers/$provider'; - final queryParams = {}; - queryParams['app_id'] = appId.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('app_id', appId.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'PUT', @@ -768,10 +801,13 @@ class AuthClient { /// DELETE /v1/admin/social/providers/{provider} Future socialAdminDeleteProvider({required String provider, required String appId, required String token}) async { final path = '/v1/admin/social/providers/$provider'; - final queryParams = {}; - queryParams['app_id'] = appId.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('app_id', appId.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'DELETE', @@ -785,10 +821,13 @@ class AuthClient { /// GET /v1/admin/sso/connections Future ssoAdminListConnections({required String appId, required String token}) async { final path = '/v1/admin/sso/connections'; - final queryParams = {}; - queryParams['app_id'] = appId.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('app_id', appId.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -852,10 +891,13 @@ class AuthClient { /// GET /v1/admin/stats Future adminGetStats({required String appId, required String token}) async { final path = '/v1/admin/stats'; - final queryParams = {}; - queryParams['app_id'] = appId.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('app_id', appId.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -869,13 +911,16 @@ class AuthClient { /// GET /v1/admin/users Future adminListUsers({required String appId, required String token, String? email, String? cursor, int? limit}) async { final path = '/v1/admin/users'; - final queryParams = {}; - queryParams['app_id'] = appId.toString(); - if (email != null) queryParams['email'] = email.toString(); - if (cursor != null) queryParams['cursor'] = cursor.toString(); - if (limit != null) queryParams['limit'] = limit.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('app_id', appId.toString())); + if (email != null) queryPairs.add(MapEntry('email', email.toString())); + if (cursor != null) queryPairs.add(MapEntry('cursor', cursor.toString())); + if (limit != null) queryPairs.add(MapEntry('limit', limit.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -977,10 +1022,13 @@ class AuthClient { /// GET /v1/billing/coupons Future listCoupons({required String appId, required String token}) async { final path = '/v1/billing/coupons'; - final queryParams = {}; - queryParams['app_id'] = appId.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('app_id', appId.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -1030,11 +1078,14 @@ class AuthClient { /// GET /v1/billing/invoices Future listInvoices({required String appId, required String tenantId, required String token}) async { final path = '/v1/billing/invoices'; - final queryParams = {}; - queryParams['app_id'] = appId.toString(); - queryParams['tenant_id'] = tenantId.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('app_id', appId.toString())); + queryPairs.add(MapEntry('tenant_id', tenantId.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -1084,10 +1135,13 @@ class AuthClient { /// GET /v1/billing/plans Future listBillingPlans({required String appId, required String token}) async { final path = '/v1/billing/plans'; - final queryParams = {}; - queryParams['app_id'] = appId.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('app_id', appId.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -1148,12 +1202,15 @@ class AuthClient { /// GET /v1/billing/subscriptions Future listSubscriptions({required String appId, required String tenantId, required String token, String? status}) async { final path = '/v1/billing/subscriptions'; - final queryParams = {}; - queryParams['app_id'] = appId.toString(); - queryParams['tenant_id'] = tenantId.toString(); - if (status != null) queryParams['status'] = status.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('app_id', appId.toString())); + queryPairs.add(MapEntry('tenant_id', tenantId.toString())); + if (status != null) queryPairs.add(MapEntry('status', status.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -1180,11 +1237,14 @@ class AuthClient { /// GET /v1/billing/subscriptions/active Future getActiveSubscription({required String appId, required String tenantId, required String token}) async { final path = '/v1/billing/subscriptions/active'; - final queryParams = {}; - queryParams['app_id'] = appId.toString(); - queryParams['tenant_id'] = tenantId.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('app_id', appId.toString())); + queryPairs.add(MapEntry('tenant_id', tenantId.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -1244,11 +1304,14 @@ class AuthClient { /// GET /v1/billing/usage Future getUsageSummary({required String appId, required String tenantId, required String token}) async { final path = '/v1/billing/usage'; - final queryParams = {}; - queryParams['app_id'] = appId.toString(); - queryParams['tenant_id'] = tenantId.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('app_id', appId.toString())); + queryPairs.add(MapEntry('tenant_id', tenantId.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -1275,10 +1338,13 @@ class AuthClient { /// GET /v1/client-config Future getClientConfig({required String token, String? key}) async { final path = '/v1/client-config'; - final queryParams = {}; - if (key != null) queryParams['key'] = key.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + if (key != null) queryPairs.add(MapEntry('key', key.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -1292,12 +1358,15 @@ class AuthClient { /// GET /v1/consent Future listConsents({required String purpose, required String cursor, required int limit, required String token}) async { final path = '/v1/consent'; - final queryParams = {}; - queryParams['purpose'] = purpose.toString(); - queryParams['cursor'] = cursor.toString(); - queryParams['limit'] = limit.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('purpose', purpose.toString())); + queryPairs.add(MapEntry('cursor', cursor.toString())); + queryPairs.add(MapEntry('limit', limit.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -1385,10 +1454,13 @@ class AuthClient { /// GET /v1/environments Future listEnvironments({required String appId, required String token}) async { final path = '/v1/environments'; - final queryParams = {}; - queryParams['app_id'] = appId.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('app_id', appId.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -1537,11 +1609,14 @@ class AuthClient { /// GET /v1/keys Future listAPIKeys({required String appId, required String token, String? userId}) async { final path = '/v1/keys'; - final queryParams = {}; - queryParams['app_id'] = appId.toString(); - if (userId != null) queryParams['user_id'] = userId.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('app_id', appId.toString())); + if (userId != null) queryPairs.add(MapEntry('user_id', userId.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -1787,18 +1862,24 @@ class AuthClient { /// OAuth2 Authorization /// GET /v1/oauth/authorize - Future oauth2Authorize({required String responseType, required String clientId, String? redirectUri, String? scope, String? state, String? codeChallenge, String? codeChallengeMethod}) async { + Future oauth2Authorize({required String responseType, required String clientId, String? redirectUri, String? scope, String? state, String? codeChallenge, String? codeChallengeMethod, List? resource}) async { final path = '/v1/oauth/authorize'; - final queryParams = {}; - queryParams['response_type'] = responseType.toString(); - queryParams['client_id'] = clientId.toString(); - if (redirectUri != null) queryParams['redirect_uri'] = redirectUri.toString(); - if (scope != null) queryParams['scope'] = scope.toString(); - if (state != null) queryParams['state'] = state.toString(); - if (codeChallenge != null) queryParams['code_challenge'] = codeChallenge.toString(); - if (codeChallengeMethod != null) queryParams['code_challenge_method'] = codeChallengeMethod.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('response_type', responseType.toString())); + queryPairs.add(MapEntry('client_id', clientId.toString())); + if (redirectUri != null) queryPairs.add(MapEntry('redirect_uri', redirectUri.toString())); + if (scope != null) queryPairs.add(MapEntry('scope', scope.toString())); + if (state != null) queryPairs.add(MapEntry('state', state.toString())); + if (codeChallenge != null) queryPairs.add(MapEntry('code_challenge', codeChallenge.toString())); + if (codeChallengeMethod != null) queryPairs.add(MapEntry('code_challenge_method', codeChallengeMethod.toString())); + for (final element in resource ?? const []) { + queryPairs.add(MapEntry('resource', element.toString())); + } + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; await _request( 'GET', @@ -1949,11 +2030,14 @@ class AuthClient { /// GET /v1/orgs/check-slug Future checkOrgSlug({required String appId, required String slug, required String token}) async { final path = '/v1/orgs/check-slug'; - final queryParams = {}; - queryParams['app_id'] = appId.toString(); - queryParams['slug'] = slug.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('app_id', appId.toString())); + queryPairs.add(MapEntry('slug', slug.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -2313,10 +2397,13 @@ class AuthClient { /// GET /v1/roles Future authsomeListRoles({required String appId, required String token}) async { final path = '/v1/roles'; - final queryParams = {}; - queryParams['app_id'] = appId.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('app_id', appId.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -2503,11 +2590,14 @@ class AuthClient { /// POST /v1/social/{provider} Future startOAuth({required String provider, String? frontendUrl, String? redirectUrl}) async { final path = '/v1/social/$provider'; - final queryParams = {}; - if (frontendUrl != null) queryParams['frontend_url'] = frontendUrl.toString(); - if (redirectUrl != null) queryParams['redirect_url'] = redirectUrl.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + if (frontendUrl != null) queryPairs.add(MapEntry('frontend_url', frontendUrl.toString())); + if (redirectUrl != null) queryPairs.add(MapEntry('redirect_url', redirectUrl.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'POST', @@ -2520,12 +2610,15 @@ class AuthClient { /// GET /v1/social/{provider}/callback Future oauthCallback({required String provider, String? state, String? code, String? error}) async { final path = '/v1/social/$provider/callback'; - final queryParams = {}; - if (state != null) queryParams['state'] = state.toString(); - if (code != null) queryParams['code'] = code.toString(); - if (error != null) queryParams['error'] = error.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + if (state != null) queryPairs.add(MapEntry('state', state.toString())); + if (code != null) queryPairs.add(MapEntry('code', code.toString())); + if (error != null) queryPairs.add(MapEntry('error', error.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -2585,12 +2678,15 @@ class AuthClient { /// POST /v1/sso/{provider}/callback Future ssoCallback({required String provider, String? state, String? code, String? error}) async { final path = '/v1/sso/$provider/callback'; - final queryParams = {}; - if (state != null) queryParams['state'] = state.toString(); - if (code != null) queryParams['code'] = code.toString(); - if (error != null) queryParams['error'] = error.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + if (state != null) queryPairs.add(MapEntry('state', state.toString())); + if (code != null) queryPairs.add(MapEntry('code', code.toString())); + if (error != null) queryPairs.add(MapEntry('error', error.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'POST', @@ -2603,10 +2699,13 @@ class AuthClient { /// POST /v1/sso/{provider}/login Future startSSOLogin({required String provider, String? returnUrl}) async { final path = '/v1/sso/$provider/login'; - final queryParams = {}; - if (returnUrl != null) queryParams['return_url'] = returnUrl.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + if (returnUrl != null) queryPairs.add(MapEntry('return_url', returnUrl.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'POST', @@ -2643,10 +2742,13 @@ class AuthClient { /// GET /v1/users/{userId}/roles Future authsomeListUserRoles({required String userId, required String token, String? appId}) async { final path = '/v1/users/$userId/roles'; - final queryParams = {}; - if (appId != null) queryParams['app_id'] = appId.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + if (appId != null) queryPairs.add(MapEntry('app_id', appId.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -2684,10 +2786,13 @@ class AuthClient { /// GET /v1/webhooks Future listWebhooks({required String appId, required String token}) async { final path = '/v1/webhooks'; - final queryParams = {}; - queryParams['app_id'] = appId.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('app_id', appId.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', diff --git a/plugins/oauth2provider/plugin.go b/plugins/oauth2provider/plugin.go index e5f86be5..4912ee7c 100644 --- a/plugins/oauth2provider/plugin.go +++ b/plugins/oauth2provider/plugin.go @@ -534,11 +534,10 @@ type AuthorizeRequest struct { State string `query:"state,omitempty"` CodeChallenge string `query:"code_challenge,omitempty"` CodeChallengeMethod string `query:"code_challenge_method,omitempty"` - // No resource field. go-utils' bindFormParam gained multi-value binding in - // v1.1.7, but bindQueryParam still reads one value through c.Query, so a - // []string query field silently keeps the first value and drops the rest. - // That is worse than the error it used to raise, so the authorization - // endpoint reads the raw query; see resourceParams. + // RFC 8707, repeatable. A field rather than a raw-request read, because + // only a declared field reaches the OpenAPI document, and only a described + // parameter reaches the generated clients. + Resource []string `query:"resource,omitempty"` } // TokenRequest is the OAuth2 token request. @@ -754,9 +753,8 @@ func (p *Plugin) handleAuthorize(ctx forge.Context, req *AuthorizeRequest) (*api return nil, err } - // RFC 8707. Read off the raw query: the struct binder still collapses a - // repeated query parameter to its first value; see resourceParams. - resources, err := resolveResources(client, resourceParams(ctx.Request())) + // RFC 8707. + resources, err := resolveResources(client, req.Resource) if err != nil { return nil, err } diff --git a/plugins/oauth2provider/resource.go b/plugins/oauth2provider/resource.go index c0f8aba3..9da64039 100644 --- a/plugins/oauth2provider/resource.go +++ b/plugins/oauth2provider/resource.go @@ -7,30 +7,6 @@ import ( "github.com/xraph/authsome/internal/resourceuri" ) -// resourceParams reads the repeatable RFC 8707 resource parameter off a query -// string. -// -// Form bodies no longer need this: go-utils v1.1.7 taught bindFormParam to -// fill a []string from every occurrence of a parameter, so the token and -// device endpoints bind theirs through the struct. bindQueryParam did not get -// the same treatment. It still reads a single value through c.Query, and -// setFieldValue's new slice case then splits that one value on commas, so a -// []string query field keeps the first resource and silently discards the -// rest. Reading the query directly is the only way the authorization endpoint -// sees every value it was sent. -// -// The cost is that the parameter stays out of the OpenAPI document, since -// forge describes query parameters by reflecting over the request struct and -// nothing else. No generated client can send a resource indicator to -// /authorize until bindQueryParam handles repeated values. -func resourceParams(r *http.Request) []string { - if r == nil { - return nil - } - - return r.URL.Query()["resource"] -} - // resourceURISyntaxError checks a single RFC 8707 resource indicator against // the syntax rule shared by request-time resolution and admin registration: // the value must be an absolute URI and must not carry a fragment. It returns diff --git a/sdk/dart/lib/src/client.dart b/sdk/dart/lib/src/client.dart index f52d4222..3d96381f 100644 --- a/sdk/dart/lib/src/client.dart +++ b/sdk/dart/lib/src/client.dart @@ -384,10 +384,13 @@ class AuthClient { /// DELETE /v1/admin/bulk/sessions Future adminBulkRevokeSessions({required String userId, required String token}) async { final path = '/v1/admin/bulk/sessions'; - final queryParams = {}; - queryParams['user_id'] = userId.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('user_id', userId.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'DELETE', @@ -438,10 +441,13 @@ class AuthClient { /// GET /v1/admin/oauth/clients Future listOAuth2Clients({required String appId, required String token}) async { final path = '/v1/admin/oauth/clients'; - final queryParams = {}; - queryParams['app_id'] = appId.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('app_id', appId.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -479,10 +485,13 @@ class AuthClient { /// GET /v1/admin/orgs Future adminListOrgs({required String appId, required String token}) async { final path = '/v1/admin/orgs'; - final queryParams = {}; - queryParams['app_id'] = appId.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('app_id', appId.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -521,12 +530,15 @@ class AuthClient { /// GET /v1/admin/service-accounts Future adminListServiceAccounts({required String appId, required String token, int? limit, String? cursor}) async { final path = '/v1/admin/service-accounts'; - final queryParams = {}; - queryParams['app_id'] = appId.toString(); - if (limit != null) queryParams['limit'] = limit.toString(); - if (cursor != null) queryParams['cursor'] = cursor.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('app_id', appId.toString())); + if (limit != null) queryPairs.add(MapEntry('limit', limit.toString())); + if (cursor != null) queryPairs.add(MapEntry('cursor', cursor.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -590,11 +602,14 @@ class AuthClient { /// GET /v1/admin/settings/definitions Future listSettingsDefinitions({required String namespace, required String category, required String token}) async { final path = '/v1/admin/settings/definitions'; - final queryParams = {}; - queryParams['namespace'] = namespace.toString(); - queryParams['category'] = category.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('namespace', namespace.toString())); + queryPairs.add(MapEntry('category', category.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -633,11 +648,14 @@ class AuthClient { /// DELETE /v1/admin/settings/enforce/{key} Future unenforceSetting({required String key, required String scope, required String scopeId, required String token}) async { final path = '/v1/admin/settings/enforce/$key'; - final queryParams = {}; - queryParams['scope'] = scope.toString(); - queryParams['scope_id'] = scopeId.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('scope', scope.toString())); + queryPairs.add(MapEntry('scope_id', scopeId.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'DELETE', @@ -651,13 +669,16 @@ class AuthClient { /// GET /v1/admin/settings/resolve Future resolveSettings({required String namespace, required String appId, required String orgId, required String userId, required String token}) async { final path = '/v1/admin/settings/resolve'; - final queryParams = {}; - queryParams['namespace'] = namespace.toString(); - queryParams['app_id'] = appId.toString(); - queryParams['org_id'] = orgId.toString(); - queryParams['user_id'] = userId.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('namespace', namespace.toString())); + queryPairs.add(MapEntry('app_id', appId.toString())); + queryPairs.add(MapEntry('org_id', orgId.toString())); + queryPairs.add(MapEntry('user_id', userId.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -671,12 +692,15 @@ class AuthClient { /// GET /v1/admin/settings/resolve/{key} Future resolveSetting({required String key, required String appId, required String orgId, required String userId, required String token}) async { final path = '/v1/admin/settings/resolve/$key'; - final queryParams = {}; - queryParams['app_id'] = appId.toString(); - queryParams['org_id'] = orgId.toString(); - queryParams['user_id'] = userId.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('app_id', appId.toString())); + queryPairs.add(MapEntry('org_id', orgId.toString())); + queryPairs.add(MapEntry('user_id', userId.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -703,11 +727,14 @@ class AuthClient { /// DELETE /v1/admin/settings/values/{key} Future deleteSetting({required String key, required String scope, required String scopeId, required String token}) async { final path = '/v1/admin/settings/values/$key'; - final queryParams = {}; - queryParams['scope'] = scope.toString(); - queryParams['scope_id'] = scopeId.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('scope', scope.toString())); + queryPairs.add(MapEntry('scope_id', scopeId.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'DELETE', @@ -721,10 +748,13 @@ class AuthClient { /// GET /v1/admin/social/providers Future socialAdminListProviders({required String appId, required String token}) async { final path = '/v1/admin/social/providers'; - final queryParams = {}; - queryParams['app_id'] = appId.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('app_id', appId.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -750,10 +780,13 @@ class AuthClient { /// PUT /v1/admin/social/providers/{provider} Future socialAdminUpsertProvider({required String provider, required AdminUpsertProviderRequest body, required String appId, required String token}) async { final path = '/v1/admin/social/providers/$provider'; - final queryParams = {}; - queryParams['app_id'] = appId.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('app_id', appId.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'PUT', @@ -768,10 +801,13 @@ class AuthClient { /// DELETE /v1/admin/social/providers/{provider} Future socialAdminDeleteProvider({required String provider, required String appId, required String token}) async { final path = '/v1/admin/social/providers/$provider'; - final queryParams = {}; - queryParams['app_id'] = appId.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('app_id', appId.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'DELETE', @@ -785,10 +821,13 @@ class AuthClient { /// GET /v1/admin/sso/connections Future ssoAdminListConnections({required String appId, required String token}) async { final path = '/v1/admin/sso/connections'; - final queryParams = {}; - queryParams['app_id'] = appId.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('app_id', appId.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -852,10 +891,13 @@ class AuthClient { /// GET /v1/admin/stats Future adminGetStats({required String appId, required String token}) async { final path = '/v1/admin/stats'; - final queryParams = {}; - queryParams['app_id'] = appId.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('app_id', appId.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -869,13 +911,16 @@ class AuthClient { /// GET /v1/admin/users Future adminListUsers({required String appId, required String token, String? email, String? cursor, int? limit}) async { final path = '/v1/admin/users'; - final queryParams = {}; - queryParams['app_id'] = appId.toString(); - if (email != null) queryParams['email'] = email.toString(); - if (cursor != null) queryParams['cursor'] = cursor.toString(); - if (limit != null) queryParams['limit'] = limit.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('app_id', appId.toString())); + if (email != null) queryPairs.add(MapEntry('email', email.toString())); + if (cursor != null) queryPairs.add(MapEntry('cursor', cursor.toString())); + if (limit != null) queryPairs.add(MapEntry('limit', limit.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -977,10 +1022,13 @@ class AuthClient { /// GET /v1/billing/coupons Future listCoupons({required String appId, required String token}) async { final path = '/v1/billing/coupons'; - final queryParams = {}; - queryParams['app_id'] = appId.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('app_id', appId.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -1030,11 +1078,14 @@ class AuthClient { /// GET /v1/billing/invoices Future listInvoices({required String appId, required String tenantId, required String token}) async { final path = '/v1/billing/invoices'; - final queryParams = {}; - queryParams['app_id'] = appId.toString(); - queryParams['tenant_id'] = tenantId.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('app_id', appId.toString())); + queryPairs.add(MapEntry('tenant_id', tenantId.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -1084,10 +1135,13 @@ class AuthClient { /// GET /v1/billing/plans Future listBillingPlans({required String appId, required String token}) async { final path = '/v1/billing/plans'; - final queryParams = {}; - queryParams['app_id'] = appId.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('app_id', appId.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -1148,12 +1202,15 @@ class AuthClient { /// GET /v1/billing/subscriptions Future listSubscriptions({required String appId, required String tenantId, required String token, String? status}) async { final path = '/v1/billing/subscriptions'; - final queryParams = {}; - queryParams['app_id'] = appId.toString(); - queryParams['tenant_id'] = tenantId.toString(); - if (status != null) queryParams['status'] = status.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('app_id', appId.toString())); + queryPairs.add(MapEntry('tenant_id', tenantId.toString())); + if (status != null) queryPairs.add(MapEntry('status', status.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -1180,11 +1237,14 @@ class AuthClient { /// GET /v1/billing/subscriptions/active Future getActiveSubscription({required String appId, required String tenantId, required String token}) async { final path = '/v1/billing/subscriptions/active'; - final queryParams = {}; - queryParams['app_id'] = appId.toString(); - queryParams['tenant_id'] = tenantId.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('app_id', appId.toString())); + queryPairs.add(MapEntry('tenant_id', tenantId.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -1244,11 +1304,14 @@ class AuthClient { /// GET /v1/billing/usage Future getUsageSummary({required String appId, required String tenantId, required String token}) async { final path = '/v1/billing/usage'; - final queryParams = {}; - queryParams['app_id'] = appId.toString(); - queryParams['tenant_id'] = tenantId.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('app_id', appId.toString())); + queryPairs.add(MapEntry('tenant_id', tenantId.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -1275,10 +1338,13 @@ class AuthClient { /// GET /v1/client-config Future getClientConfig({required String token, String? key}) async { final path = '/v1/client-config'; - final queryParams = {}; - if (key != null) queryParams['key'] = key.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + if (key != null) queryPairs.add(MapEntry('key', key.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -1292,12 +1358,15 @@ class AuthClient { /// GET /v1/consent Future listConsents({required String purpose, required String cursor, required int limit, required String token}) async { final path = '/v1/consent'; - final queryParams = {}; - queryParams['purpose'] = purpose.toString(); - queryParams['cursor'] = cursor.toString(); - queryParams['limit'] = limit.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('purpose', purpose.toString())); + queryPairs.add(MapEntry('cursor', cursor.toString())); + queryPairs.add(MapEntry('limit', limit.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -1385,10 +1454,13 @@ class AuthClient { /// GET /v1/environments Future listEnvironments({required String appId, required String token}) async { final path = '/v1/environments'; - final queryParams = {}; - queryParams['app_id'] = appId.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('app_id', appId.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -1537,11 +1609,14 @@ class AuthClient { /// GET /v1/keys Future listAPIKeys({required String appId, required String token, String? userId}) async { final path = '/v1/keys'; - final queryParams = {}; - queryParams['app_id'] = appId.toString(); - if (userId != null) queryParams['user_id'] = userId.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('app_id', appId.toString())); + if (userId != null) queryPairs.add(MapEntry('user_id', userId.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -1787,18 +1862,24 @@ class AuthClient { /// OAuth2 Authorization /// GET /v1/oauth/authorize - Future oauth2Authorize({required String responseType, required String clientId, String? redirectUri, String? scope, String? state, String? codeChallenge, String? codeChallengeMethod}) async { + Future oauth2Authorize({required String responseType, required String clientId, String? redirectUri, String? scope, String? state, String? codeChallenge, String? codeChallengeMethod, List? resource}) async { final path = '/v1/oauth/authorize'; - final queryParams = {}; - queryParams['response_type'] = responseType.toString(); - queryParams['client_id'] = clientId.toString(); - if (redirectUri != null) queryParams['redirect_uri'] = redirectUri.toString(); - if (scope != null) queryParams['scope'] = scope.toString(); - if (state != null) queryParams['state'] = state.toString(); - if (codeChallenge != null) queryParams['code_challenge'] = codeChallenge.toString(); - if (codeChallengeMethod != null) queryParams['code_challenge_method'] = codeChallengeMethod.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('response_type', responseType.toString())); + queryPairs.add(MapEntry('client_id', clientId.toString())); + if (redirectUri != null) queryPairs.add(MapEntry('redirect_uri', redirectUri.toString())); + if (scope != null) queryPairs.add(MapEntry('scope', scope.toString())); + if (state != null) queryPairs.add(MapEntry('state', state.toString())); + if (codeChallenge != null) queryPairs.add(MapEntry('code_challenge', codeChallenge.toString())); + if (codeChallengeMethod != null) queryPairs.add(MapEntry('code_challenge_method', codeChallengeMethod.toString())); + for (final element in resource ?? const []) { + queryPairs.add(MapEntry('resource', element.toString())); + } + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; await _request( 'GET', @@ -1949,11 +2030,14 @@ class AuthClient { /// GET /v1/orgs/check-slug Future checkOrgSlug({required String appId, required String slug, required String token}) async { final path = '/v1/orgs/check-slug'; - final queryParams = {}; - queryParams['app_id'] = appId.toString(); - queryParams['slug'] = slug.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('app_id', appId.toString())); + queryPairs.add(MapEntry('slug', slug.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -2313,10 +2397,13 @@ class AuthClient { /// GET /v1/roles Future authsomeListRoles({required String appId, required String token}) async { final path = '/v1/roles'; - final queryParams = {}; - queryParams['app_id'] = appId.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('app_id', appId.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -2503,11 +2590,14 @@ class AuthClient { /// POST /v1/social/{provider} Future startOAuth({required String provider, String? frontendUrl, String? redirectUrl}) async { final path = '/v1/social/$provider'; - final queryParams = {}; - if (frontendUrl != null) queryParams['frontend_url'] = frontendUrl.toString(); - if (redirectUrl != null) queryParams['redirect_url'] = redirectUrl.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + if (frontendUrl != null) queryPairs.add(MapEntry('frontend_url', frontendUrl.toString())); + if (redirectUrl != null) queryPairs.add(MapEntry('redirect_url', redirectUrl.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'POST', @@ -2520,12 +2610,15 @@ class AuthClient { /// GET /v1/social/{provider}/callback Future oauthCallback({required String provider, String? state, String? code, String? error}) async { final path = '/v1/social/$provider/callback'; - final queryParams = {}; - if (state != null) queryParams['state'] = state.toString(); - if (code != null) queryParams['code'] = code.toString(); - if (error != null) queryParams['error'] = error.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + if (state != null) queryPairs.add(MapEntry('state', state.toString())); + if (code != null) queryPairs.add(MapEntry('code', code.toString())); + if (error != null) queryPairs.add(MapEntry('error', error.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -2585,12 +2678,15 @@ class AuthClient { /// POST /v1/sso/{provider}/callback Future ssoCallback({required String provider, String? state, String? code, String? error}) async { final path = '/v1/sso/$provider/callback'; - final queryParams = {}; - if (state != null) queryParams['state'] = state.toString(); - if (code != null) queryParams['code'] = code.toString(); - if (error != null) queryParams['error'] = error.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + if (state != null) queryPairs.add(MapEntry('state', state.toString())); + if (code != null) queryPairs.add(MapEntry('code', code.toString())); + if (error != null) queryPairs.add(MapEntry('error', error.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'POST', @@ -2603,10 +2699,13 @@ class AuthClient { /// POST /v1/sso/{provider}/login Future startSSOLogin({required String provider, String? returnUrl}) async { final path = '/v1/sso/$provider/login'; - final queryParams = {}; - if (returnUrl != null) queryParams['return_url'] = returnUrl.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + if (returnUrl != null) queryPairs.add(MapEntry('return_url', returnUrl.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'POST', @@ -2643,10 +2742,13 @@ class AuthClient { /// GET /v1/users/{userId}/roles Future authsomeListUserRoles({required String userId, required String token, String? appId}) async { final path = '/v1/users/$userId/roles'; - final queryParams = {}; - if (appId != null) queryParams['app_id'] = appId.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + if (appId != null) queryPairs.add(MapEntry('app_id', appId.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', @@ -2684,10 +2786,13 @@ class AuthClient { /// GET /v1/webhooks Future listWebhooks({required String appId, required String token}) async { final path = '/v1/webhooks'; - final queryParams = {}; - queryParams['app_id'] = appId.toString(); - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; + queryPairs.add(MapEntry('app_id', appId.toString())); + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; final res = await _request( 'GET', diff --git a/sdk/dart/test/form_encoding_test.dart b/sdk/dart/test/form_encoding_test.dart index 6ea31f71..cff49c89 100644 --- a/sdk/dart/test/form_encoding_test.dart +++ b/sdk/dart/test/form_encoding_test.dart @@ -108,4 +108,60 @@ void main() { ); }); }); + + group('query-string parameters', () { + // A query string carries repeated keys, the same as a form body does. The + // authorization endpoint reads every `resource` it is given, so a list has + // to be written one element at a time rather than stringified. + ({AuthClient client, Uri Function() url}) clientRecordingUrl() { + var seen = Uri.parse('https://auth.example.com'); + + final client = AuthClient(AuthClientConfig( + baseUrl: 'https://auth.example.com', + httpClient: MockClient((request) async { + seen = request.url; + return http.Response('', 204); + }), + )); + + return (client: client, url: () => seen); + } + + test('sends a one-element list as a single parameter', () async { + final recorder = clientRecordingUrl(); + + await recorder.client.oauth2Authorize( + responseType: 'code', + clientId: 'the-client', + resource: ['https://api.example.com'], + ); + + expect(recorder.url().queryParametersAll['resource'], + ['https://api.example.com']); + }); + + test('repeats the parameter once per element for a two-element list', + () async { + final recorder = clientRecordingUrl(); + + await recorder.client.oauth2Authorize( + responseType: 'code', + clientId: 'the-client', + resource: ['https://a.example.com', 'https://b.example.com'], + ); + + expect(recorder.url().queryParametersAll['resource'], + ['https://a.example.com', 'https://b.example.com']); + }); + + test('keeps an absent list off the query string', () async { + final recorder = clientRecordingUrl(); + + await recorder.client + .oauth2Authorize(responseType: 'code', clientId: 'the-client'); + + expect(recorder.url().queryParametersAll.containsKey('resource'), + isFalse); + }); + }); } diff --git a/sdk/go/client.go b/sdk/go/client.go index 068b00be..0b73d923 100644 --- a/sdk/go/client.go +++ b/sdk/go/client.go @@ -2074,6 +2074,12 @@ func (c *Client) Oauth2Authorize(ctx context.Context, params *Oauth2AuthorizePar if params.CodeChallengeMethod != "" { q.Set("code_challenge_method", params.CodeChallengeMethod) } + // Repeated once per element. A query string carries repeated keys, not + // lists, which is how RFC 8707 sends `resource`. A slice is also not + // comparable, so the zero-value check below would not compile for one. + for _, v := range params.Resource { + q.Add("resource", v) + } if encoded := q.Encode(); encoded != "" { path += "?" + encoded } @@ -3267,13 +3273,14 @@ type ListAPIKeysParams struct { // Oauth2AuthorizeParams holds optional query parameters for Oauth2Authorize. type Oauth2AuthorizeParams struct { - ResponseType string `json:"response_type,omitempty"` - ClientID string `json:"client_id,omitempty"` - RedirectURI string `json:"redirect_uri,omitempty"` - Scope string `json:"scope,omitempty"` - State string `json:"state,omitempty"` - CodeChallenge string `json:"code_challenge,omitempty"` - CodeChallengeMethod string `json:"code_challenge_method,omitempty"` + ResponseType string `json:"response_type,omitempty"` + ClientID string `json:"client_id,omitempty"` + RedirectURI string `json:"redirect_uri,omitempty"` + Scope string `json:"scope,omitempty"` + State string `json:"state,omitempty"` + CodeChallenge string `json:"code_challenge,omitempty"` + CodeChallengeMethod string `json:"code_challenge_method,omitempty"` + Resource []string `json:"resource,omitempty"` } // CheckOrgSlugParams holds optional query parameters for CheckOrgSlug. diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index 5f6623b1..8abe4c3c 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -2162,7 +2162,7 @@ export class AuthClient { * OAuth2 Authorization * GET /v1/oauth/authorize */ - async oauth2Authorize(response_type: string, client_id: string, redirect_uri?: string, scope?: string, state?: string, code_challenge?: string, code_challenge_method?: string): Promise { + async oauth2Authorize(response_type: string, client_id: string, redirect_uri?: string, scope?: string, state?: string, code_challenge?: string, code_challenge_method?: string, resource?: string[]): Promise { const params = new URLSearchParams(); if (response_type !== undefined) params.set('response_type', String(response_type)); if (client_id !== undefined) params.set('client_id', String(client_id)); @@ -2171,6 +2171,10 @@ export class AuthClient { if (state !== undefined) params.set('state', String(state)); if (code_challenge !== undefined) params.set('code_challenge', String(code_challenge)); if (code_challenge_method !== undefined) params.set('code_challenge_method', String(code_challenge_method)); + // Repeated once per element: a query string carries repeated keys, not + // lists, which is how RFC 8707 sends `resource`. String() on an array + // would join it on commas and reach the server as one value. + if (resource !== undefined) for (const element of resource) params.append('resource', String(element)); const qs = params.toString(); const path = "/v1/oauth/authorize" + (qs ? `?${qs}` : ''); return this.request( diff --git a/sdk/typescript/test/form-encoding.test.ts b/sdk/typescript/test/form-encoding.test.ts index 6f442519..743882eb 100644 --- a/sdk/typescript/test/form-encoding.test.ts +++ b/sdk/typescript/test/form-encoding.test.ts @@ -81,3 +81,57 @@ describe('form-encoded request bodies', () => { expect(new URLSearchParams(body()).has('resource')).toBe(false); }); }); + +/** + * Builds a client whose transport records the URL it was called with. + */ +function clientRecordingURL(): { client: AuthClient; url: () => string } { + let sent = ''; + + const client = new AuthClient({ + baseURL: 'https://auth.example.com', + fetch: async (url) => { + sent = String(url); + return new Response(null, { status: 204 }); + }, + }); + + return { client, url: () => sent }; +} + +describe('query-string parameters', () => { + // A query string carries repeated keys, the same as a form body does. The + // authorization endpoint reads every `resource` it is given, so an array has + // to be written one element at a time rather than stringified. + it('sends a one-element array as a single parameter', async () => { + const { client, url } = clientRecordingURL(); + + await client.oauth2Authorize('code', 'the-client', undefined, undefined, undefined, undefined, undefined, [ + 'https://api.example.com', + ]); + + expect(new URL(url()).searchParams.getAll('resource')).toEqual(['https://api.example.com']); + }); + + it('repeats the parameter once per element for a two-element array', async () => { + const { client, url } = clientRecordingURL(); + + await client.oauth2Authorize('code', 'the-client', undefined, undefined, undefined, undefined, undefined, [ + 'https://a.example.com', + 'https://b.example.com', + ]); + + expect(new URL(url()).searchParams.getAll('resource')).toEqual([ + 'https://a.example.com', + 'https://b.example.com', + ]); + }); + + it('keeps an absent array off the query string', async () => { + const { client, url } = clientRecordingURL(); + + await client.oauth2Authorize('code', 'the-client'); + + expect(new URL(url()).searchParams.has('resource')).toBe(false); + }); +}); diff --git a/sdkgen/cmd/specgen/resource_parameter_test.go b/sdkgen/cmd/specgen/resource_parameter_test.go index 4622bb9c..75a24a9a 100644 --- a/sdkgen/cmd/specgen/resource_parameter_test.go +++ b/sdkgen/cmd/specgen/resource_parameter_test.go @@ -58,20 +58,10 @@ func queryParameter(t *testing.T, spec map[string]any, path, method, name string // if the spec describes it, and the spec only describes parameters that exist // as fields on the handler's request struct. // -// The field cannot exist yet. go-utils v1.1.7 taught bindFormParam to fill a -// []string from a repeated parameter, which is why the device endpoint below -// carries one, but bindQueryParam still reads a single value through c.Query. -// A []string query field would therefore bind the first resource and silently -// drop the rest, which is worse than the error the old binder raised, so -// handleAuthorize reads the raw query instead and the parameter stays -// undescribed. -// -// Unskip this once bindQueryParam handles repeated values: add -// `Resource []string` with a query tag to AuthorizeRequest, drop -// resourceParams, and regenerate. +// Reading the raw request instead left the endpoint working over curl and +// unreachable from every SDK, which is the state it was in until go-utils +// taught bindQueryParam to keep every occurrence of a repeated parameter. func TestSpec_AuthorizeExposesRepeatableResource(t *testing.T) { - t.Skip("blocked on go-utils bindQueryParam, which collapses a repeated query parameter to its first value") - param := queryParameter(t, committedSpec(t), "/v1/oauth/authorize", "get", "resource") require.NotNil(t, param, "the authorize endpoint should describe a resource query parameter") diff --git a/sdkgen/dart/generator.go b/sdkgen/dart/generator.go index 5f047fbd..938470e7 100644 --- a/sdkgen/dart/generator.go +++ b/sdkgen/dart/generator.go @@ -517,6 +517,12 @@ func (g *Generator) responseType(op *openapi.Operation) string { func (g *Generator) renderTemplate(name string, data *TemplateData) (string, error) { funcMap := template.FuncMap{ "lower": strings.ToLower, + // A list-typed parameter is written once per element rather than + // stringified, since List.toString() emits Dart's bracket notation and + // a query string carries repeated keys rather than lists. + "isArrayType": func(t string) bool { + return strings.HasPrefix(t, "List<") + }, "dartType": func(t string, optional bool) string { if optional { return t + "?" diff --git a/sdkgen/dart/templates/client.dart.tmpl b/sdkgen/dart/templates/client.dart.tmpl index cf176bb2..c309ee0e 100644 --- a/sdkgen/dart/templates/client.dart.tmpl +++ b/sdkgen/dart/templates/client.dart.tmpl @@ -74,16 +74,31 @@ class AuthClient { Future<{{ if isVoid $op.ResponseType }}void{{ else }}{{ $op.ResponseType }}{{ end }}> {{ $op.Name }}({{ buildDartParams $op }}) async { final path = {{ buildDartPath $op.Path $op.PathParams }}; {{- if hasQueryParams $op.QueryParams }} - final queryParams = {}; + // A list of pairs rather than a map, because a query string may carry the + // same key more than once. RFC 8707 sends `resource` that way, and a map + // would keep only the last value. + final queryPairs = >[]; {{- range $op.QueryParams }} +{{- if isArrayType .Type }} {{- if .Required }} - queryParams['{{ .Name }}'] = {{ .DartName }}.toString(); + for (final element in {{ .DartName }}) { + queryPairs.add(MapEntry('{{ .Name }}', element.toString())); + } +{{- else }} + for (final element in {{ .DartName }} ?? const []) { + queryPairs.add(MapEntry('{{ .Name }}', element.toString())); + } +{{- end }} {{- else }} - if ({{ .DartName }} != null) queryParams['{{ .Name }}'] = {{ .DartName }}.toString(); +{{- if .Required }} + queryPairs.add(MapEntry('{{ .Name }}', {{ .DartName }}.toString())); +{{- else }} + if ({{ .DartName }} != null) queryPairs.add(MapEntry('{{ .Name }}', {{ .DartName }}.toString())); +{{- end }} {{- end }} {{- end }} - final queryString = queryParams.isNotEmpty - ? '?${queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' + final queryString = queryPairs.isNotEmpty + ? '?${queryPairs.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}' : ''; {{- end }} {{ if isVoid $op.ResponseType }} await _request( diff --git a/sdkgen/golang/templates/client.go.tmpl b/sdkgen/golang/templates/client.go.tmpl index a6568532..e1643e84 100644 --- a/sdkgen/golang/templates/client.go.tmpl +++ b/sdkgen/golang/templates/client.go.tmpl @@ -317,6 +317,13 @@ func (c *Client) {{ .Name }}(ctx context.Context if params.{{ .GoName }} { q.Set("{{ .Name }}", "true") } +{{- else if eq .Type "[]string" }} + // Repeated once per element. A query string carries repeated keys, not + // lists, which is how RFC 8707 sends `resource`. A slice is also not + // comparable, so the zero-value check below would not compile for one. + for _, v := range params.{{ .GoName }} { + q.Add("{{ .Name }}", v) + } {{- else }} var zero{{ .GoName }} {{ .Type }} if params.{{ .GoName }} != zero{{ .GoName }} { diff --git a/sdkgen/spec.json b/sdkgen/spec.json index 83bbe246..330ac98d 100644 --- a/sdkgen/spec.json +++ b/sdkgen/spec.json @@ -27089,6 +27089,16 @@ "schema": { "type": "string" } + }, + { + "in": "query", + "name": "resource", + "schema": { + "items": { + "type": "string" + }, + "type": "array" + } } ], "responses": { diff --git a/sdkgen/typescript/generator.go b/sdkgen/typescript/generator.go index f98d6619..05d6be46 100644 --- a/sdkgen/typescript/generator.go +++ b/sdkgen/typescript/generator.go @@ -488,6 +488,12 @@ func (g *Generator) renderTemplate(name string, data *TemplateData) (string, err "hasQueryParams": func(params []QueryParamDef) bool { return len(params) > 0 }, + // An array-typed parameter is written once per element rather than + // stringified, since String(["a","b"]) joins on commas and puts one + // value on the wire where the caller meant two. + "isArrayType": func(t string) bool { + return strings.HasSuffix(t, "[]") + }, "buildPath": func(path string, params []PathParamDef) string { if len(params) == 0 { return `"` + path + `"` diff --git a/sdkgen/typescript/templates/client.ts.tmpl b/sdkgen/typescript/templates/client.ts.tmpl index 6aecf29f..9ccf8294 100644 --- a/sdkgen/typescript/templates/client.ts.tmpl +++ b/sdkgen/typescript/templates/client.ts.tmpl @@ -166,7 +166,14 @@ export class AuthClient { {{- if hasQueryParams $op.QueryParams }} const params = new URLSearchParams(); {{- range $op.QueryParams }} +{{- if isArrayType .Type }} + // Repeated once per element: a query string carries repeated keys, not + // lists, which is how RFC 8707 sends `resource`. String() on an array + // would join it on commas and reach the server as one value. + if ({{ .TSName }} !== undefined) for (const element of {{ .TSName }}) params.append('{{ .Name }}', String(element)); +{{- else }} if ({{ .TSName }} !== undefined) params.set('{{ .Name }}', String({{ .TSName }})); +{{- end }} {{- end }} const qs = params.toString(); const path = {{ buildPath $op.Path $op.PathParams }} + (qs ? `?${qs}` : ''); diff --git a/ui/packages/core/src/generated/api-client.ts b/ui/packages/core/src/generated/api-client.ts index d9241314..33cb7a46 100644 --- a/ui/packages/core/src/generated/api-client.ts +++ b/ui/packages/core/src/generated/api-client.ts @@ -2256,7 +2256,7 @@ export class AuthClient { * OAuth2 Authorization * GET /v1/oauth/authorize */ - async oauth2Authorize(response_type: string, client_id: string, redirect_uri?: string, scope?: string, state?: string, code_challenge?: string, code_challenge_method?: string): Promise { + async oauth2Authorize(response_type: string, client_id: string, redirect_uri?: string, scope?: string, state?: string, code_challenge?: string, code_challenge_method?: string, resource?: string[]): Promise { const params = new URLSearchParams(); if (response_type !== undefined) params.set('response_type', String(response_type)); if (client_id !== undefined) params.set('client_id', String(client_id)); @@ -2265,6 +2265,10 @@ export class AuthClient { if (state !== undefined) params.set('state', String(state)); if (code_challenge !== undefined) params.set('code_challenge', String(code_challenge)); if (code_challenge_method !== undefined) params.set('code_challenge_method', String(code_challenge_method)); + // Repeated once per element: a query string carries repeated keys, not + // lists, which is how RFC 8707 sends `resource`. String() on an array + // would join it on commas and reach the server as one value. + if (resource !== undefined) for (const element of resource) params.append('resource', String(element)); const qs = params.toString(); const path = "/v1/oauth/authorize" + (qs ? `?${qs}` : ''); return this.request( From 7c3f53aeca19656efcbca454b84aadc273c7d1f8 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 25 Aug 2026 15:57:50 -0500 Subject: [PATCH 2/2] chore(deps): move to go-utils v1.1.8, unblocking this branch xraph/go-utils#4 shipped as v1.1.8. bindQueryParam now fills a []string from every occurrence of a repeated query parameter, the way bindFormParam already did, and a lone value still expands on commas so scope=openid,profile is unaffected. That is the release the previous commit said it was waiting for. The three tests it listed as failing against v1.1.7 pass now, and they fail for the right reason without this bump: two_resources_both_land_on_the_code kept only the first resource, and the two TestTokenResource cases were downstream of the same collapse. Nothing else moves. The spec and all three SDKs regenerate byte-identical, the full suite passes and the linter is quiet, because the code this unblocks was already written and only the dependency was missing. --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 17efbe3b..ff7562bf 100644 --- a/go.mod +++ b/go.mod @@ -182,7 +182,7 @@ require ( github.com/x448/float16 v0.8.4 // indirect github.com/xraph/confy v1.0.2 // indirect github.com/xraph/dispatch v1.6.2 - github.com/xraph/go-utils v1.1.7 + github.com/xraph/go-utils v1.1.8 github.com/xraph/ledger v1.6.1 github.com/xraph/vault v1.6.1 github.com/yusufpapurcu/wmi v1.2.4 // indirect diff --git a/go.sum b/go.sum index 20964053..104dd9a0 100644 --- a/go.sum +++ b/go.sum @@ -436,8 +436,8 @@ github.com/xraph/forge/extensions/auth v1.9.11 h1:toPgfeWOPFU74/+EPmqdA9copZIF7S github.com/xraph/forge/extensions/auth v1.9.11/go.mod h1:iOQGV/iZA5+HY5vE47lDIAO+Qp2wIG8ZIDw41Pn5CDQ= github.com/xraph/forgeui v1.4.1 h1:LHK1t/sZ+9zL+MNUZralO9/rc0f5UCa19dpbWTuRMNg= github.com/xraph/forgeui v1.4.1/go.mod h1:rH/+wb1tt2pXSHotWAvoP+Lt846xlIjuwPDSpS5K5mw= -github.com/xraph/go-utils v1.1.7 h1:PvW6H5VZhiLt07qQrGTGQqPiVwsbVyeQg2oxuNmrBxA= -github.com/xraph/go-utils v1.1.7/go.mod h1:Mckdi+nR0bI4bUESKSYajJq4tNSPsvZiuLRYJ0+qDQw= +github.com/xraph/go-utils v1.1.8 h1:O8+Vie/u/ntn2cEbvh47jJLzQ6S7qwxhYwgRm2SL1sw= +github.com/xraph/go-utils v1.1.8/go.mod h1:Mckdi+nR0bI4bUESKSYajJq4tNSPsvZiuLRYJ0+qDQw= github.com/xraph/grove v1.6.2 h1:O/3UyHTKQQ57CyZiLkDQi5T7xyzMSBz320VlK3C04Vo= github.com/xraph/grove v1.6.2/go.mod h1:bgjHNhnmyfEyzbdpcppRt+Zf24nNcbGKlo450Mi4giI= github.com/xraph/grove/drivers/mongodriver v1.6.2 h1:vyuSb2Fu6pRM7xb2DhtQattDCXyOm//TKUcMnwlkD9M=