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/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= 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(