diff --git a/packages/supabase_auth/lib/src/auth_admin_api.dart b/packages/supabase_auth/lib/src/auth_admin_api.dart index 4dd1b924c..0928513af 100644 --- a/packages/supabase_auth/lib/src/auth_admin_api.dart +++ b/packages/supabase_auth/lib/src/auth_admin_api.dart @@ -69,6 +69,11 @@ class AuthAdminApi { headers: _headers, fetch: _fetch, ); + sso = AuthAdminSSOApi( + url: _url, + headers: _headers, + fetch: _fetch, + ); } final String _url; final Map _headers; @@ -92,6 +97,10 @@ class AuthAdminApi { @experimental late final AuthAdminPasskeyApi passkey; + /// Contains all single sign-on (SAML 2.0) identity provider administration + /// methods. + late final AuthAdminSSOApi sso; + /// Removes a logged-in session. Future signOut( String jwt, { diff --git a/packages/supabase_auth/lib/src/auth_admin_sso_api.dart b/packages/supabase_auth/lib/src/auth_admin_sso_api.dart new file mode 100644 index 000000000..5a1e23b1a --- /dev/null +++ b/packages/supabase_auth/lib/src/auth_admin_sso_api.dart @@ -0,0 +1,114 @@ +import 'package:supabase_common/supabase_common.dart'; + +import 'fetch.dart'; +import 'types/fetch_options.dart'; +import 'types/sso_provider.dart'; + +/// Contains all single sign-on (SAML 2.0) identity provider administration +/// methods. +/// +/// A provider is addressed by its UUID, or by `resource_` when it +/// was registered with a resource ID. +class AuthAdminSSOApi { + const AuthAdminSSOApi({ + required String url, + required Map headers, + required AuthFetch fetch, + }) : _url = url, + _headers = headers, + _fetch = fetch; + final String _url; + final Map _headers; + final AuthFetch _fetch; + + String _providerUrl(String id) => + '$_url/admin/sso/providers/${Uri.encodeComponent(id)}'; + + /// Lists the registered providers, optionally only those whose resource ID + /// is [resourceId] or starts with [resourceIdPrefix]. + /// + /// The listed providers leave out [SAMLProvider.metadataXml], fetch a single + /// provider with [getProvider] to read it. + /// + /// This function should only be called on a server. Never expose your + /// `secret` key on the client. + Future> listProviders({ + String? resourceId, + String? resourceIdPrefix, + }) async { + final data = await _fetch.request( + '$_url/admin/sso/providers', + HttpMethod.get, + options: AuthRequestOptions( + headers: _headers, + query: { + 'resource_id': ?resourceId, + 'resource_id_prefix': ?resourceIdPrefix, + }, + ), + ); + + final providers = (data['items'] as List?) ?? const []; + return [ + for (final provider in providers) + SSOProvider.fromJson(provider as Map), + ]; + } + + /// Registers a new SAML 2.0 identity provider. + /// + /// This function should only be called on a server. Never expose your + /// `secret` key on the client. + Future createProvider(CreateSSOProviderOptions options) async { + final data = await _fetch.request( + '$_url/admin/sso/providers', + HttpMethod.post, + options: AuthRequestOptions(headers: _headers, body: options.toJson()), + ); + + return SSOProvider.fromJson(data); + } + + /// Gets the provider with the given [id], including its metadata XML. + /// + /// This function should only be called on a server. Never expose your + /// `secret` key on the client. + Future getProvider(String id) async { + final data = await _fetch.request( + _providerUrl(id), + HttpMethod.get, + options: AuthRequestOptions(headers: _headers), + ); + + return SSOProvider.fromJson(data); + } + + /// Applies [options] to the provider with the given [id]. + /// + /// This function should only be called on a server. Never expose your + /// `secret` key on the client. + Future updateProvider( + String id, + UpdateSSOProviderOptions options, + ) async { + final data = await _fetch.request( + _providerUrl(id), + HttpMethod.put, + options: AuthRequestOptions(headers: _headers, body: options.toJson()), + ); + + return SSOProvider.fromJson(data); + } + + /// Removes the provider with the given [id] together with its domains. + /// + /// This function should only be called on a server. Never expose your + /// `secret` key on the client. + Future deleteProvider(String id) async { + await _fetch.request( + _providerUrl(id), + HttpMethod.delete, + options: AuthRequestOptions(headers: _headers, noResolveJson: true), + ); + } +} diff --git a/packages/supabase_auth/lib/src/types/sso_provider.dart b/packages/supabase_auth/lib/src/types/sso_provider.dart new file mode 100644 index 000000000..67ed5bd6d --- /dev/null +++ b/packages/supabase_auth/lib/src/types/sso_provider.dart @@ -0,0 +1,309 @@ +import 'package:collection/collection.dart'; + +/// Format of the SAML `NameID` a single sign-on identity provider asserts to +/// identify the user. +enum SAMLNameIdFormat { + /// A stable, opaque identifier that stays the same across sign-ins. + persistent('urn:oasis:names:tc:SAML:2.0:nameid-format:persistent'), + + /// The user's email address. + emailAddress('urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'), + + /// A one-time identifier that changes on every sign-in. + transient('urn:oasis:names:tc:SAML:2.0:nameid-format:transient'), + + /// Leaves the format up to the identity provider. + unspecified('urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified'); + + const SAMLNameIdFormat(this.urn); + + /// The URN that identifies this format in SAML metadata and on the wire. + final String urn; + + /// Parses a format from its [urn], returning null for one this version of + /// the client does not recognize. + static SAMLNameIdFormat? fromUrn(String urn) { + return SAMLNameIdFormat.values.firstWhereOrNull( + (format) => format.urn == urn, + ); + } +} + +/// Where the SAML 2.0 metadata of a single sign-on identity provider comes +/// from. +sealed class SAMLMetadata { + const SAMLMetadata(); + + /// Metadata the Auth server fetches from [url], which must use HTTPS. + /// + /// The server fetches and parses the document before answering, so the + /// request takes as long as that outbound fetch. + const factory SAMLMetadata.url(Uri url) = SAMLMetadataUrl; + + /// Metadata given inline as an XML document. + const factory SAMLMetadata.xml(String xml) = SAMLMetadataXml; + + Map toJson(); +} + +/// SAML metadata the Auth server fetches from [url]. +final class SAMLMetadataUrl extends SAMLMetadata { + const SAMLMetadataUrl(this.url); + + /// The HTTPS URL the metadata document is served from. + final Uri url; + + @override + Map toJson() => {'metadata_url': url.toString()}; +} + +/// SAML metadata given inline as an XML document. +final class SAMLMetadataXml extends SAMLMetadata { + const SAMLMetadataXml(this.xml); + + /// The metadata XML document. + final String xml; + + @override + Map toJson() => {'metadata_xml': xml}; +} + +/// How one user attribute is read from the SAML assertion. +class SAMLAttribute { + const SAMLAttribute({ + this.name, + this.names, + this.defaultValue, + this.array = false, + }); + + factory SAMLAttribute.fromJson(Map json) { + return SAMLAttribute( + name: json['name'] as String?, + names: (json['names'] as List?)?.cast(), + defaultValue: json['default'], + array: json['array'] as bool? ?? false, + ); + } + + /// The name of the assertion attribute to read. + final String? name; + + /// Assertion attribute names to try in order, the first one present wins. + final List? names; + + /// The value used when the assertion carries none of the attributes. + final Object? defaultValue; + + /// Whether to keep every value of a multi-valued attribute instead of only + /// the first one. + final bool array; + + Map toJson() => { + 'name': ?name, + 'names': ?names, + 'default': ?defaultValue, + if (array) 'array': true, + }; +} + +/// Maps SAML assertion attributes to user metadata keys. +class SAMLAttributeMapping { + const SAMLAttributeMapping(this.keys); + + factory SAMLAttributeMapping.fromJson(Map json) { + final keys = (json['keys'] as Map?)?.cast() ?? const {}; + return SAMLAttributeMapping({ + for (final MapEntry(:key, :value) in keys.entries) + key: SAMLAttribute.fromJson(value as Map), + }); + } + + /// The attribute to read for each user metadata key. + final Map keys; + + Map toJson() => { + 'keys': { + for (final MapEntry(:key, :value) in keys.entries) key: value.toJson(), + }, + }; +} + +/// The SAML 2.0 configuration of a single sign-on identity provider. +class SAMLProvider { + const SAMLProvider({ + required this.entityId, + this.metadataXml, + this.metadataUrl, + this.attributeMapping, + this.nameIdFormat, + }); + + factory SAMLProvider.fromJson(Map json) { + final metadataUrl = json['metadata_url'] as String?; + final attributeMapping = json['attribute_mapping'] as Map?; + final nameIdFormat = json['name_id_format'] as String?; + return SAMLProvider( + entityId: json['entity_id'] as String, + metadataXml: json['metadata_xml'] as String?, + metadataUrl: metadataUrl == null ? null : Uri.parse(metadataUrl), + attributeMapping: attributeMapping == null + ? null + : SAMLAttributeMapping.fromJson(attributeMapping.cast()), + nameIdFormat: nameIdFormat == null + ? null + : SAMLNameIdFormat.fromUrn(nameIdFormat), + ); + } + + /// The entity ID from the identity provider's metadata. + final String entityId; + + /// The identity provider's metadata XML. + /// + /// Null on providers returned by `listProviders`, which leaves it out. + final String? metadataXml; + + /// The URL the metadata was fetched from, when it was registered by URL. + final Uri? metadataUrl; + + /// How assertion attributes map to user metadata. + final SAMLAttributeMapping? attributeMapping; + + /// The `NameID` format requested from the identity provider. + final SAMLNameIdFormat? nameIdFormat; +} + +/// A single sign-on identity provider registered with Supabase Auth. +class SSOProvider { + const SSOProvider({ + required this.id, + this.resourceId, + this.disabled = false, + this.saml, + this.domains = const [], + required this.createdAt, + required this.updatedAt, + }); + + factory SSOProvider.fromJson(Map json) { + final saml = json['saml'] as Map?; + final domains = (json['domains'] as List?) ?? const []; + return SSOProvider( + id: json['id'] as String, + resourceId: json['resource_id'] as String?, + disabled: json['disabled'] as bool? ?? false, + saml: saml == null ? null : SAMLProvider.fromJson(saml.cast()), + domains: [ + for (final domain in domains) + (domain as Map)['domain'] as String, + ], + createdAt: DateTime.parse(json['created_at'] as String), + updatedAt: DateTime.parse(json['updated_at'] as String), + ); + } + + /// The provider's UUID. + final String id; + + /// An identifier of your choosing that the provider can also be addressed + /// by, as `resource_` in place of [id]. + final String? resourceId; + + /// Whether sign-in through this provider is turned off. + final bool disabled; + + /// The provider's SAML 2.0 configuration. + final SAMLProvider? saml; + + /// The email domains whose users sign in through this provider. + final List domains; + + final DateTime createdAt; + final DateTime updatedAt; +} + +/// The options to register a single sign-on identity provider with. +class CreateSSOProviderOptions { + const CreateSSOProviderOptions({ + required this.metadata, + this.domains, + this.attributeMapping, + this.nameIdFormat, + this.resourceId, + this.disabled, + }); + + /// The identity provider's SAML 2.0 metadata. + final SAMLMetadata metadata; + + /// The email domains whose users sign in through this provider. + final List? domains; + + /// How assertion attributes map to user metadata. + final SAMLAttributeMapping? attributeMapping; + + /// The `NameID` format to request from the identity provider. + final SAMLNameIdFormat? nameIdFormat; + + /// An identifier of your choosing to address the provider by. + final String? resourceId; + + /// Whether to register the provider with sign-in turned off. + final bool? disabled; + + Map toJson() => { + 'type': 'saml', + ...metadata.toJson(), + 'domains': ?domains, + 'attribute_mapping': ?attributeMapping?.toJson(), + 'name_id_format': ?nameIdFormat?.urn, + 'resource_id': ?resourceId, + 'disabled': ?disabled, + }; +} + +/// The changes to apply to a registered single sign-on identity provider. +/// +/// A null field keeps the stored value, except for [nameIdFormat], which the +/// server clears when it is not sent. +class UpdateSSOProviderOptions { + const UpdateSSOProviderOptions({ + required this.nameIdFormat, + this.metadata, + this.domains, + this.attributeMapping, + this.resourceId, + this.disabled, + }); + + /// The `NameID` format to request from the identity provider. + /// + /// Required because null clears the stored format. Pass the provider's + /// current [SAMLProvider.nameIdFormat] to keep it. + final SAMLNameIdFormat? nameIdFormat; + + /// New SAML metadata, which must carry the provider's current entity ID. + final SAMLMetadata? metadata; + + /// Replaces the provider's email domains. An empty list removes them all. + final List? domains; + + /// Replaces the attribute mapping. A mapping with no keys clears it. + final SAMLAttributeMapping? attributeMapping; + + /// A new resource ID. An empty string removes the stored one. + final String? resourceId; + + /// Turns sign-in through the provider off or back on. + final bool? disabled; + + Map toJson() => { + ...?metadata?.toJson(), + 'domains': ?domains, + 'attribute_mapping': ?attributeMapping?.toJson(), + 'name_id_format': ?nameIdFormat?.urn, + 'resource_id': ?resourceId, + 'disabled': ?disabled, + }; +} diff --git a/packages/supabase_auth/lib/supabase_auth.dart b/packages/supabase_auth/lib/supabase_auth.dart index 2fe40b828..47efbcb3f 100644 --- a/packages/supabase_auth/lib/supabase_auth.dart +++ b/packages/supabase_auth/lib/supabase_auth.dart @@ -22,9 +22,11 @@ export 'src/types/error_code.dart'; export 'src/types/auth_async_storage.dart'; export 'src/types/jwt.dart'; export 'src/auth_admin_passkey_api.dart'; +export 'src/auth_admin_sso_api.dart'; export 'src/types/mfa.dart'; export 'src/types/passkey.dart'; export 'src/types/sign_out_reason.dart'; +export 'src/types/sso_provider.dart'; export 'src/types/types.dart'; export 'src/types/session.dart'; export 'src/types/user.dart'; diff --git a/packages/supabase_auth/test/admin_sso_test.dart b/packages/supabase_auth/test/admin_sso_test.dart new file mode 100644 index 000000000..110fd316d --- /dev/null +++ b/packages/supabase_auth/test/admin_sso_test.dart @@ -0,0 +1,315 @@ +import 'package:supabase_auth/supabase_auth.dart'; +import 'package:supabase_common/supabase_common.dart'; +import 'package:test/test.dart'; + +import 'utils.dart'; + +const _providerId = '6d1b8e0b-9a5f-4b3e-8f1c-2f0d4b7a9c11'; + +Map _providerJson({ + bool includeMetadataXml = true, + Map? attributeMapping, + String? nameIdFormat, +}) => { + 'id': _providerId, + 'resource_id': 'acme', + 'disabled': null, + 'saml': { + 'entity_id': 'https://idp.example.com/metadata', + if (includeMetadataXml) 'metadata_xml': '', + 'metadata_url': 'https://idp.example.com/metadata', + 'attribute_mapping': ?attributeMapping, + 'name_id_format': ?nameIdFormat, + }, + 'domains': [ + {'domain': 'acme.com'}, + {'domain': 'acme.org'}, + ], + 'created_at': '2026-01-01T00:00:00Z', + 'updated_at': '2026-01-02T00:00:00Z', +}; + +void main() { + AuthClient clientWith(MockSupabaseHttpClient mockClient) => AuthClient( + url: 'http://localhost:9999', + httpClient: mockClient, + asyncStorage: TestAsyncStorage(), + ); + + group('SAMLNameIdFormat', () { + test('encodes each format as its SAML URN', () { + expect( + SAMLNameIdFormat.persistent.urn, + 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent', + ); + expect( + SAMLNameIdFormat.emailAddress.urn, + 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', + ); + expect( + SAMLNameIdFormat.transient.urn, + 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient', + ); + expect( + SAMLNameIdFormat.unspecified.urn, + 'urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified', + ); + }); + + test('parses every format from its URN', () { + for (final format in SAMLNameIdFormat.values) { + expect(SAMLNameIdFormat.fromUrn(format.urn), format); + } + }); + + test('parses an unrecognized URN as null', () { + expect(SAMLNameIdFormat.fromUrn('urn:example:unknown'), isNull); + }); + }); + + group('CreateSSOProviderOptions', () { + test('serializes a metadata URL with only the required fields', () { + final options = CreateSSOProviderOptions( + metadata: SAMLMetadata.url( + Uri.parse('https://idp.example.com/metadata'), + ), + ); + + expect(options.toJson(), { + 'type': 'saml', + 'metadata_url': 'https://idp.example.com/metadata', + }); + }); + + test('serializes metadata XML and every optional field', () { + final options = CreateSSOProviderOptions( + metadata: const SAMLMetadata.xml(''), + domains: ['acme.com'], + attributeMapping: const SAMLAttributeMapping({ + 'email': SAMLAttribute(name: 'mail'), + 'groups': SAMLAttribute( + names: ['memberOf', 'groups'], + defaultValue: ['users'], + array: true, + ), + }), + nameIdFormat: SAMLNameIdFormat.persistent, + resourceId: 'acme', + disabled: true, + ); + + expect(options.toJson(), { + 'type': 'saml', + 'metadata_xml': '', + 'domains': ['acme.com'], + 'attribute_mapping': { + 'keys': { + 'email': {'name': 'mail'}, + 'groups': { + 'names': ['memberOf', 'groups'], + 'default': ['users'], + 'array': true, + }, + }, + }, + 'name_id_format': + 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent', + 'resource_id': 'acme', + 'disabled': true, + }); + }); + }); + + group('UpdateSSOProviderOptions', () { + test('omits the fields that keep their stored value', () { + const options = UpdateSSOProviderOptions( + nameIdFormat: SAMLNameIdFormat.emailAddress, + ); + + expect(options.toJson(), { + 'name_id_format': + 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', + }); + }); + + test('sends no name_id_format when it is null, which clears it', () { + const options = UpdateSSOProviderOptions(nameIdFormat: null); + + expect(options.toJson(), isEmpty); + }); + + test('sends empty domains and attribute mapping to clear them', () { + const options = UpdateSSOProviderOptions( + nameIdFormat: null, + domains: [], + attributeMapping: SAMLAttributeMapping({}), + ); + + expect(options.toJson(), { + 'domains': [], + 'attribute_mapping': {'keys': {}}, + }); + }); + + test('serializes new metadata, resource ID and disabled state', () { + final options = UpdateSSOProviderOptions( + nameIdFormat: null, + metadata: SAMLMetadata.url( + Uri.parse('https://idp.example.com/metadata'), + ), + resourceId: '', + disabled: false, + ); + + expect(options.toJson(), { + 'metadata_url': 'https://idp.example.com/metadata', + 'resource_id': '', + 'disabled': false, + }); + }); + }); + + group('SSOProvider.fromJson', () { + test('parses a provider with its SAML configuration', () { + final provider = SSOProvider.fromJson( + _providerJson( + attributeMapping: { + 'keys': { + 'email': {'name': 'mail'}, + 'groups': { + 'names': ['memberOf'], + 'array': true, + }, + }, + }, + nameIdFormat: SAMLNameIdFormat.transient.urn, + ), + ); + + expect(provider.id, _providerId); + expect(provider.resourceId, 'acme'); + expect(provider.disabled, isFalse); + expect(provider.domains, ['acme.com', 'acme.org']); + expect(provider.createdAt, DateTime.utc(2026)); + expect(provider.updatedAt, DateTime.utc(2026, 1, 2)); + + final saml = provider.saml!; + expect(saml.entityId, 'https://idp.example.com/metadata'); + expect(saml.metadataXml, ''); + expect(saml.metadataUrl, Uri.parse('https://idp.example.com/metadata')); + expect(saml.nameIdFormat, SAMLNameIdFormat.transient); + expect(saml.attributeMapping!.keys['email']!.name, 'mail'); + expect(saml.attributeMapping!.keys['groups']!.names, ['memberOf']); + expect(saml.attributeMapping!.keys['groups']!.array, isTrue); + }); + + test('leaves the metadata XML null when the server omits it', () { + final provider = SSOProvider.fromJson( + _providerJson(includeMetadataXml: false), + ); + + expect(provider.saml!.metadataXml, isNull); + expect(provider.saml!.attributeMapping, isNull); + expect(provider.saml!.nameIdFormat, isNull); + }); + }); + + group('AuthAdminSSOApi', () { + test('listProviders() sends the resource ID filters', () async { + final mockClient = MockSupabaseHttpClient() + ..stub({ + 'items': [_providerJson(includeMetadataXml: false)], + }); + + final providers = await clientWith(mockClient).admin.sso.listProviders( + resourceIdPrefix: 'ac', + ); + + expect(providers.single.id, _providerId); + final request = mockClient + .requestsTo( + '/admin/sso/providers', + method: HttpMethod.get.value, + ) + .single; + expect(request.queryParameters, {'resource_id_prefix': 'ac'}); + }); + + test('createProvider() accepts the 201 the server answers with', () async { + final mockClient = MockSupabaseHttpClient() + ..stub(_providerJson(), statusCode: 201); + + final provider = await clientWith(mockClient).admin.sso.createProvider( + const CreateSSOProviderOptions( + metadata: SAMLMetadata.xml(''), + domains: ['acme.com'], + ), + ); + + expect(provider.id, _providerId); + final request = mockClient + .requestsTo( + '/admin/sso/providers', + method: HttpMethod.post.value, + ) + .single; + expect(request.jsonBody, { + 'type': 'saml', + 'metadata_xml': '', + 'domains': ['acme.com'], + }); + }); + + test('getProvider() addresses a provider by resource ID', () async { + final mockClient = MockSupabaseHttpClient()..stub(_providerJson()); + + await clientWith(mockClient).admin.sso.getProvider('resource_acme'); + + expect( + mockClient.requestsTo( + '/admin/sso/providers/resource_acme', + method: HttpMethod.get.value, + ), + hasLength(1), + ); + }); + + test('updateProvider() sends the options with PUT', () async { + final mockClient = MockSupabaseHttpClient()..stub(_providerJson()); + + await clientWith(mockClient).admin.sso.updateProvider( + _providerId, + const UpdateSSOProviderOptions( + nameIdFormat: SAMLNameIdFormat.persistent, + domains: ['acme.com'], + ), + ); + + final request = mockClient + .requestsTo( + '/admin/sso/providers/$_providerId', + method: HttpMethod.put.value, + ) + .single; + expect(request.jsonBody, { + 'domains': ['acme.com'], + 'name_id_format': + 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent', + }); + }); + + test('deleteProvider() sends DELETE to the provider', () async { + final mockClient = MockSupabaseHttpClient()..stub(_providerJson()); + + await clientWith(mockClient).admin.sso.deleteProvider(_providerId); + + expect( + mockClient.requestsTo( + '/admin/sso/providers/$_providerId', + method: HttpMethod.delete.value, + ), + hasLength(1), + ); + }); + }); +} diff --git a/packages/supabase_auth/test/admin_test.dart b/packages/supabase_auth/test/admin_test.dart index 4ddfff4a2..c235c7fb1 100644 --- a/packages/supabase_auth/test/admin_test.dart +++ b/packages/supabase_auth/test/admin_test.dart @@ -252,4 +252,116 @@ void main() { ); }); }); + + group('SSO providers', () { + const resourceId = 'sdk-test-sso'; + const entityId = 'https://idp.example.com/sdk-test'; + const metadataXml = + '' + '' + '' + '' + ''; + + Future deleteTestProviders() async { + final providers = await client.admin.sso.listProviders( + resourceId: resourceId, + ); + for (final provider in providers) { + await client.admin.sso.deleteProvider(provider.id); + } + } + + setUp(deleteTestProviders); + tearDown(deleteTestProviders); + + Future createTestProvider() { + return client.admin.sso.createProvider( + const CreateSSOProviderOptions( + metadata: SAMLMetadata.xml(metadataXml), + domains: ['sdk-test.example'], + attributeMapping: SAMLAttributeMapping({ + 'email': SAMLAttribute(name: 'mail'), + }), + nameIdFormat: SAMLNameIdFormat.persistent, + resourceId: resourceId, + ), + ); + } + + test('creates, gets, lists and deletes a provider', () async { + final created = await createTestProvider(); + + expect(created.resourceId, resourceId); + expect(created.disabled, isFalse); + expect(created.domains, ['sdk-test.example']); + expect(created.saml!.entityId, entityId); + expect(created.saml!.metadataXml, metadataXml); + expect(created.saml!.nameIdFormat, SAMLNameIdFormat.persistent); + expect(created.saml!.attributeMapping!.keys['email']!.name, 'mail'); + + final fetched = await client.admin.sso.getProvider( + 'resource_$resourceId', + ); + expect(fetched.id, created.id); + expect(fetched.saml!.metadataXml, metadataXml); + + final listed = await client.admin.sso.listProviders( + resourceIdPrefix: 'sdk-test', + ); + expect(listed.map((provider) => provider.id), contains(created.id)); + expect(listed.single.saml!.metadataXml, isNull); + + await client.admin.sso.deleteProvider(created.id); + + await expectLater( + client.admin.sso.getProvider(created.id), + throwsA( + isA().having( + (exception) => exception.errorCode, + 'errorCode', + ErrorCode.ssoProviderNotFound.code, + ), + ), + ); + }); + + test('updateProvider() keeps the fields it leaves out', () async { + final created = await createTestProvider(); + + final disabled = await client.admin.sso.updateProvider( + created.id, + UpdateSSOProviderOptions( + nameIdFormat: created.saml!.nameIdFormat, + disabled: true, + ), + ); + + expect(disabled.disabled, isTrue); + expect(disabled.domains, ['sdk-test.example']); + expect(disabled.saml!.nameIdFormat, SAMLNameIdFormat.persistent); + expect(disabled.saml!.attributeMapping!.keys, contains('email')); + }); + + test('updateProvider() clears what it is given empty', () async { + final created = await createTestProvider(); + + final cleared = await client.admin.sso.updateProvider( + created.id, + const UpdateSSOProviderOptions( + nameIdFormat: null, + domains: [], + attributeMapping: SAMLAttributeMapping({}), + ), + ); + + expect(cleared.domains, isEmpty); + expect(cleared.saml!.nameIdFormat, isNull); + expect(cleared.saml!.attributeMapping?.keys ?? {}, isEmpty); + }); + }); } diff --git a/sdk-compliance.yaml b/sdk-compliance.yaml index 68a4d98f7..0adc8b7d7 100644 --- a/sdk-compliance.yaml +++ b/sdk-compliance.yaml @@ -805,6 +805,99 @@ features: symbols: - AuthAdminPasskeyApi.deletePasskey + # auth — admin SSO providers + auth.sso_admin.create_provider: + status: implemented + symbols: + - AuthAdminSSOApi.createProvider + supporting_symbols: + - AuthAdminApi.sso + - AuthAdminSSOApi + - AuthAdminSSOApi.AuthAdminSSOApi + - CreateSSOProviderOptions + - CreateSSOProviderOptions.CreateSSOProviderOptions + - CreateSSOProviderOptions.attributeMapping + - CreateSSOProviderOptions.disabled + - CreateSSOProviderOptions.domains + - CreateSSOProviderOptions.metadata + - CreateSSOProviderOptions.nameIdFormat + - CreateSSOProviderOptions.resourceId + - CreateSSOProviderOptions.toJson + - SAMLAttribute + - SAMLAttribute.SAMLAttribute + - SAMLAttribute.array + - SAMLAttribute.defaultValue + - SAMLAttribute.fromJson + - SAMLAttribute.name + - SAMLAttribute.names + - SAMLAttribute.toJson + - SAMLAttributeMapping + - SAMLAttributeMapping.SAMLAttributeMapping + - SAMLAttributeMapping.fromJson + - SAMLAttributeMapping.keys + - SAMLAttributeMapping.toJson + - SAMLMetadata + - SAMLMetadata.SAMLMetadata + - SAMLMetadata.toJson + - SAMLMetadata.url + - SAMLMetadata.xml + - SAMLMetadataUrl + - SAMLMetadataUrl.SAMLMetadataUrl + - SAMLMetadataUrl.toJson + - SAMLMetadataUrl.url + - SAMLMetadataXml + - SAMLMetadataXml.SAMLMetadataXml + - SAMLMetadataXml.toJson + - SAMLMetadataXml.xml + - SAMLNameIdFormat + - SAMLNameIdFormat.SAMLNameIdFormat + - SAMLNameIdFormat.fromUrn + - SAMLNameIdFormat.urn + - SAMLProvider + - SAMLProvider.SAMLProvider + - SAMLProvider.attributeMapping + - SAMLProvider.entityId + - SAMLProvider.fromJson + - SAMLProvider.metadataUrl + - SAMLProvider.metadataXml + - SAMLProvider.nameIdFormat + - SSOProvider + - SSOProvider.SSOProvider + - SSOProvider.createdAt + - SSOProvider.disabled + - SSOProvider.domains + - SSOProvider.fromJson + - SSOProvider.id + - SSOProvider.resourceId + - SSOProvider.saml + - SSOProvider.updatedAt + auth.sso_admin.get_provider: + status: implemented + symbols: + - AuthAdminSSOApi.getProvider + auth.sso_admin.list_providers: + status: implemented + symbols: + - AuthAdminSSOApi.listProviders + auth.sso_admin.update_provider: + status: implemented + symbols: + - AuthAdminSSOApi.updateProvider + supporting_symbols: + - UpdateSSOProviderOptions + - UpdateSSOProviderOptions.UpdateSSOProviderOptions + - UpdateSSOProviderOptions.attributeMapping + - UpdateSSOProviderOptions.disabled + - UpdateSSOProviderOptions.domains + - UpdateSSOProviderOptions.metadata + - UpdateSSOProviderOptions.nameIdFormat + - UpdateSSOProviderOptions.resourceId + - UpdateSSOProviderOptions.toJson + auth.sso_admin.delete_provider: + status: implemented + symbols: + - AuthAdminSSOApi.deleteProvider + # database — query database.query.from_table: status: implemented