diff --git a/infra/gotrue/docker-compose.yml b/infra/gotrue/docker-compose.yml index 2baaf4a5c..e4fa26bd1 100644 --- a/infra/gotrue/docker-compose.yml +++ b/infra/gotrue/docker-compose.yml @@ -2,7 +2,7 @@ version: '3' services: gotrue: # Signup enabled, autoconfirm on - image: supabase/auth:v2.175.0 + image: supabase/auth:v2.180.0 ports: - '9998:9998' environment: @@ -29,6 +29,8 @@ services: GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: 'true' GOTRUE_MFA_PHONE_ENROLL_ENABLED: 'true' GOTRUE_MFA_PHONE_VERIFY_ENABLED: 'true' + GOTRUE_OAUTH_SERVER_ENABLED: 'true' + GOTRUE_OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION: 'true' depends_on: - db diff --git a/packages/gotrue/lib/src/fetch.dart b/packages/gotrue/lib/src/fetch.dart index d8fb9c9e2..c020a78e6 100644 --- a/packages/gotrue/lib/src/fetch.dart +++ b/packages/gotrue/lib/src/fetch.dart @@ -199,7 +199,11 @@ class GotrueFetch { } try { - return json.decode(utf8.decode(response.bodyBytes)); + final bodyString = utf8.decode(response.bodyBytes); + if (bodyString.isEmpty) { + return {}; + } + return json.decode(bodyString); } catch (error) { throw _handleError(error); } diff --git a/packages/gotrue/lib/src/gotrue_admin_api.dart b/packages/gotrue/lib/src/gotrue_admin_api.dart index b3cc71879..42b5a79e5 100644 --- a/packages/gotrue/lib/src/gotrue_admin_api.dart +++ b/packages/gotrue/lib/src/gotrue_admin_api.dart @@ -6,6 +6,7 @@ import 'package:gotrue/src/types/fetch_options.dart'; import 'package:http/http.dart'; import 'gotrue_admin_mfa_api.dart'; +import 'gotrue_admin_oauth_api.dart'; class GoTrueAdminApi { final String _url; @@ -15,6 +16,10 @@ class GoTrueAdminApi { late final GotrueFetch _fetch = GotrueFetch(_httpClient); late final GoTrueAdminMFAApi mfa; + /// Contains all OAuth client administration methods. + /// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. + late final GoTrueAdminOAuthApi oauth; + GoTrueAdminApi( this._url, { Map? headers, @@ -26,6 +31,11 @@ class GoTrueAdminApi { headers: _headers, fetch: _fetch, ); + oauth = GoTrueAdminOAuthApi( + url: _url, + headers: _headers, + fetch: _fetch, + ); } /// Removes a logged-in session. diff --git a/packages/gotrue/lib/src/gotrue_admin_oauth_api.dart b/packages/gotrue/lib/src/gotrue_admin_oauth_api.dart new file mode 100644 index 000000000..76d8cd700 --- /dev/null +++ b/packages/gotrue/lib/src/gotrue_admin_oauth_api.dart @@ -0,0 +1,151 @@ +import 'fetch.dart'; +import 'helper.dart'; +import 'types/fetch_options.dart'; +import 'types/types.dart'; + +/// Response type for OAuth client operations. +/// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. +class OAuthClientResponse { + final OAuthClient? client; + + OAuthClientResponse({this.client}); + + factory OAuthClientResponse.fromJson(Map json) { + return OAuthClientResponse( + client: json.isEmpty ? null : OAuthClient.fromJson(json), + ); + } +} + +/// Response type for listing OAuth clients. +/// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. +class OAuthClientListResponse { + final List clients; + final String? aud; + + OAuthClientListResponse({ + required this.clients, + this.aud, + }); + + factory OAuthClientListResponse.fromJson(Map json) { + return OAuthClientListResponse( + clients: (json['clients'] as List) + .map((e) => OAuthClient.fromJson(e as Map)) + .toList(), + aud: json['aud'] as String?, + ); + } +} + +/// Contains all OAuth client administration methods. +/// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. +class GoTrueAdminOAuthApi { + final String _url; + final Map _headers; + final GotrueFetch _fetch; + + GoTrueAdminOAuthApi({ + required String url, + required Map headers, + required GotrueFetch fetch, + }) : _url = url, + _headers = headers, + _fetch = fetch; + + /// Lists all OAuth clients with optional pagination. + /// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. + /// + /// This function should only be called on a server. Never expose your `service_role` key in the browser. + Future listClients({ + int? page, + int? perPage, + }) async { + final data = await _fetch.request( + '$_url/admin/oauth/clients', + RequestMethodType.get, + options: GotrueRequestOptions( + headers: _headers, + query: { + if (page != null) 'page': page.toString(), + if (perPage != null) 'per_page': perPage.toString(), + }, + ), + ); + + return OAuthClientListResponse.fromJson(data); + } + + /// Creates a new OAuth client. + /// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. + /// + /// This function should only be called on a server. Never expose your `service_role` key in the browser. + Future createClient( + CreateOAuthClientParams params, + ) async { + final data = await _fetch.request( + '$_url/admin/oauth/clients', + RequestMethodType.post, + options: GotrueRequestOptions( + headers: _headers, + body: params.toJson(), + ), + ); + + return OAuthClientResponse.fromJson(data); + } + + /// Gets details of a specific OAuth client. + /// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. + /// + /// This function should only be called on a server. Never expose your `service_role` key in the browser. + Future getClient(String clientId) async { + validateUuid(clientId); + + final data = await _fetch.request( + '$_url/admin/oauth/clients/$clientId', + RequestMethodType.get, + options: GotrueRequestOptions( + headers: _headers, + ), + ); + + return OAuthClientResponse.fromJson(data); + } + + /// Deletes an OAuth client. + /// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. + /// + /// This function should only be called on a server. Never expose your `service_role` key in the browser. + Future deleteClient(String clientId) async { + validateUuid(clientId); + + final data = await _fetch.request( + '$_url/admin/oauth/clients/$clientId', + RequestMethodType.delete, + options: GotrueRequestOptions( + headers: _headers, + ), + ); + + return OAuthClientResponse.fromJson(data); + } + + /// Regenerates the secret for an OAuth client. + /// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. + /// + /// This function should only be called on a server. Never expose your `service_role` key in the browser. + Future regenerateClientSecret(String clientId) async { + validateUuid(clientId); + + final data = await _fetch.request( + '$_url/admin/oauth/clients/$clientId/regenerate_secret', + RequestMethodType.post, + options: GotrueRequestOptions( + headers: _headers, + ), + ); + + return OAuthClientResponse.fromJson(data); + } +} diff --git a/packages/gotrue/lib/src/types/types.dart b/packages/gotrue/lib/src/types/types.dart index c1001433a..ea33b680c 100644 --- a/packages/gotrue/lib/src/types/types.dart +++ b/packages/gotrue/lib/src/types/types.dart @@ -32,3 +32,179 @@ enum OAuthProvider { workos, zoom, } + +/// OAuth client grant types supported by the OAuth 2.1 server. +/// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. +enum OAuthClientGrantType { + authorizationCode('authorization_code'), + refreshToken('refresh_token'); + + final String value; + const OAuthClientGrantType(this.value); +} + +/// OAuth client response types supported by the OAuth 2.1 server. +/// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. +enum OAuthClientResponseType { + code('code'); + + final String value; + const OAuthClientResponseType(this.value); +} + +/// OAuth client type indicating whether the client can keep credentials confidential. +/// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. +enum OAuthClientType { + public('public'), + confidential('confidential'); + + final String value; + const OAuthClientType(this.value); + + static OAuthClientType fromString(String value) { + return OAuthClientType.values.firstWhere((e) => e.value == value); + } +} + +/// OAuth client registration type. +/// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. +enum OAuthClientRegistrationType { + dynamic('dynamic'), + manual('manual'); + + final String value; + const OAuthClientRegistrationType(this.value); + + static OAuthClientRegistrationType fromString(String value) { + return OAuthClientRegistrationType.values + .firstWhere((e) => e.value == value); + } +} + +/// OAuth client object returned from the OAuth 2.1 server. +/// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. +class OAuthClient { + /// Unique identifier for the OAuth client + final String clientId; + + /// Human-readable name of the OAuth client + final String clientName; + + /// Client secret (only returned on registration and regeneration) + final String? clientSecret; + + /// Type of OAuth client + final OAuthClientType clientType; + + /// Token endpoint authentication method + final String tokenEndpointAuthMethod; + + /// Registration type of the client + final OAuthClientRegistrationType registrationType; + + /// URI of the OAuth client + final String? clientUri; + + /// Array of allowed redirect URIs + final List redirectUris; + + /// Array of allowed grant types + final List grantTypes; + + /// Array of allowed response types + final List responseTypes; + + /// Scope of the OAuth client + final String? scope; + + /// Timestamp when the client was created + final String createdAt; + + /// Timestamp when the client was last updated + final String updatedAt; + + OAuthClient({ + required this.clientId, + required this.clientName, + this.clientSecret, + required this.clientType, + required this.tokenEndpointAuthMethod, + required this.registrationType, + this.clientUri, + required this.redirectUris, + required this.grantTypes, + required this.responseTypes, + this.scope, + required this.createdAt, + required this.updatedAt, + }); + + factory OAuthClient.fromJson(Map json) { + return OAuthClient( + clientId: json['client_id'] as String, + clientName: json['client_name'] as String, + clientSecret: json['client_secret'] as String?, + clientType: OAuthClientType.fromString(json['client_type'] as String), + tokenEndpointAuthMethod: json['token_endpoint_auth_method'] as String, + registrationType: OAuthClientRegistrationType.fromString( + json['registration_type'] as String), + clientUri: json['client_uri'] as String?, + redirectUris: (json['redirect_uris'] as List).cast(), + grantTypes: (json['grant_types'] as List) + .map((e) => OAuthClientGrantType.values + .firstWhere((gt) => gt.value == e as String)) + .toList(), + responseTypes: (json['response_types'] as List) + .map((e) => OAuthClientResponseType.values + .firstWhere((rt) => rt.value == e as String)) + .toList(), + scope: json['scope'] as String?, + createdAt: json['created_at'] as String, + updatedAt: json['updated_at'] as String, + ); + } +} + +/// Parameters for creating a new OAuth client. +/// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. +class CreateOAuthClientParams { + /// Human-readable name of the OAuth client + final String clientName; + + /// URI of the OAuth client + final String? clientUri; + + /// Array of allowed redirect URIs + final List redirectUris; + + /// Array of allowed grant types (optional, defaults to authorization_code and refresh_token) + final List? grantTypes; + + /// Array of allowed response types (optional, defaults to code) + final List? responseTypes; + + /// Scope of the OAuth client + final String? scope; + + CreateOAuthClientParams({ + required this.clientName, + this.clientUri, + required this.redirectUris, + this.grantTypes, + this.responseTypes, + this.scope, + }); + + Map toJson() { + return { + 'client_name': clientName, + if (clientUri != null) 'client_uri': clientUri, + 'redirect_uris': redirectUris, + if (grantTypes != null) + 'grant_types': grantTypes!.map((e) => e.value).toList(), + if (responseTypes != null) + 'response_types': responseTypes!.map((e) => e.value).toList(), + if (scope != null) 'scope': scope, + }; + } +} diff --git a/packages/gotrue/test/src/gotrue_admin_oauth_api_test.dart b/packages/gotrue/test/src/gotrue_admin_oauth_api_test.dart new file mode 100644 index 000000000..cfa295c6f --- /dev/null +++ b/packages/gotrue/test/src/gotrue_admin_oauth_api_test.dart @@ -0,0 +1,131 @@ +import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; +import 'package:dotenv/dotenv.dart'; +import 'package:gotrue/gotrue.dart'; +import 'package:http/http.dart' as http; +import 'package:test/test.dart'; + +void main() { + final env = DotEnv(); + + env.load(); // Load env variables from .env file + + final gotrueUrl = env['GOTRUE_URL'] ?? 'http://localhost:9998'; + final serviceRoleToken = JWT( + {'role': 'service_role'}, + ).sign( + SecretKey( + env['GOTRUE_JWT_SECRET'] ?? '37c304f8-51aa-419a-a1af-06154e63707a'), + ); + + late GoTrueClient client; + + setUp(() async { + final res = await http.post( + Uri.parse('http://localhost:3000/rpc/reset_and_init_auth_data'), + headers: {'x-forwarded-for': '127.0.0.1'}); + if (res.body.isNotEmpty) throw res.body; + + client = GoTrueClient( + url: gotrueUrl, + headers: { + 'Authorization': 'Bearer $serviceRoleToken', + 'apikey': serviceRoleToken, + 'x-forwarded-for': '127.0.0.1' + }, + ); + }); + + group('OAuth client management', () { + test('create OAuth client', () async { + final params = CreateOAuthClientParams( + clientName: 'Test OAuth Client', + redirectUris: ['https://example.com/callback'], + clientUri: 'https://example.com', + scope: 'openid profile email', + ); + + final res = await client.admin.oauth.createClient(params); + expect(res.client, isNotNull); + expect(res.client?.clientName, 'Test OAuth Client'); + expect(res.client?.redirectUris, ['https://example.com/callback']); + expect(res.client?.clientSecret, isNotNull); + expect(res.client?.clientId, isNotNull); + }); + + test('list OAuth clients', () async { + // First create a client + final params = CreateOAuthClientParams( + clientName: 'Test OAuth Client for List', + redirectUris: ['https://example.com/callback'], + ); + await client.admin.oauth.createClient(params); + + final res = await client.admin.oauth.listClients(); + expect(res.clients, isNotEmpty); + // aud is optional + }); + + test('get OAuth client by ID', () async { + // First create a client + final params = CreateOAuthClientParams( + clientName: 'Test OAuth Client for Get', + redirectUris: ['https://example.com/callback'], + ); + final createRes = await client.admin.oauth.createClient(params); + final clientId = createRes.client!.clientId; + + final res = await client.admin.oauth.getClient(clientId); + expect(res.client, isNotNull); + expect(res.client?.clientId, clientId); + expect(res.client?.clientName, 'Test OAuth Client for Get'); + }); + + test('regenerate OAuth client secret', () async { + // First create a client + final params = CreateOAuthClientParams( + clientName: 'Test OAuth Client for Regenerate', + redirectUris: ['https://example.com/callback'], + ); + final createRes = await client.admin.oauth.createClient(params); + final clientId = createRes.client!.clientId; + final originalSecret = createRes.client!.clientSecret; + + final res = await client.admin.oauth.regenerateClientSecret(clientId); + expect(res.client, isNotNull); + expect(res.client?.clientSecret, isNotNull); + expect(res.client?.clientSecret, isNot(originalSecret)); + }); + + test('delete OAuth client', () async { + // First create a client + final params = CreateOAuthClientParams( + clientName: 'Test OAuth Client for Delete', + redirectUris: ['https://example.com/callback'], + ); + final createRes = await client.admin.oauth.createClient(params); + final clientId = createRes.client!.clientId; + + // Delete returns 204 No Content with empty body + final res = await client.admin.oauth.deleteClient(clientId); + // The server returns 204 with no body, so client will be null + expect(res.client, isNull); + }); + }); + + group('validates ids', () { + test('getClient() validates ids', () { + expect(() => client.admin.oauth.getClient('invalid-id'), + throwsA(isA())); + }); + + test('deleteClient() validates ids', () { + expect(() => client.admin.oauth.deleteClient('invalid-id'), + throwsA(isA())); + }); + + test('regenerateClientSecret() validates ids', () { + expect(() => client.admin.oauth.regenerateClientSecret('invalid-id'), + throwsA(isA())); + }); + }); +}