diff --git a/packages/dart_firebase_admin/example/lib/main.dart b/packages/dart_firebase_admin/example/lib/main.dart index e352ed66..071af993 100644 --- a/packages/dart_firebase_admin/example/lib/main.dart +++ b/packages/dart_firebase_admin/example/lib/main.dart @@ -28,7 +28,7 @@ Future authExample(FirebaseApp admin) async { if (e.errorCode == AuthClientErrorCode.userNotFound) { print('> User not found, creating new user\n'); user = await auth.createUser( - CreateRequest(email: 'test@example.com', password: 'Test@123'), + CreateRequest(email: 'test@example.com', password: 'Test@12345'), ); } else { print('> Auth error: ${e.errorCode} - ${e.message}'); diff --git a/packages/dart_firebase_admin/lib/src/app_check/app_check.dart b/packages/dart_firebase_admin/lib/src/app_check/app_check.dart index a5297e8d..9e522a01 100644 --- a/packages/dart_firebase_admin/lib/src/app_check/app_check.dart +++ b/packages/dart_firebase_admin/lib/src/app_check/app_check.dart @@ -29,7 +29,24 @@ class AppCheck implements FirebaseService { _appCheckTokenVerifier = AppCheckTokenVerifier(app); @internal - AppCheck.internal( + factory AppCheck.internal( + FirebaseApp app, { + AppCheckRequestHandler? requestHandler, + AppCheckTokenGenerator? tokenGenerator, + AppCheckTokenVerifier? tokenVerifier, + }) { + return app.getOrInitService( + FirebaseServiceType.appCheck.name, + (app) => AppCheck._internal( + app, + requestHandler: requestHandler, + tokenGenerator: tokenGenerator, + tokenVerifier: tokenVerifier, + ), + ); + } + + AppCheck._internal( this.app, { AppCheckRequestHandler? requestHandler, AppCheckTokenGenerator? tokenGenerator, diff --git a/packages/dart_firebase_admin/lib/src/auth/auth.dart b/packages/dart_firebase_admin/lib/src/auth/auth.dart index 7577c870..a61a8ba8 100644 --- a/packages/dart_firebase_admin/lib/src/auth/auth.dart +++ b/packages/dart_firebase_admin/lib/src/auth/auth.dart @@ -4,21 +4,40 @@ part of '../auth.dart'; /// An Auth instance can have multiple tenants. class Auth extends _BaseAuth implements FirebaseService { /// Creates or returns the cached Auth instance for the given app. - factory Auth( + factory Auth(FirebaseApp app) { + return app.getOrInitService(FirebaseServiceType.auth.name, Auth._); + } + + Auth._(FirebaseApp app) + : super(app: app, authRequestHandler: AuthRequestHandler(app)); + + @internal + factory Auth.internal( FirebaseApp app, { - @internal AuthRequestHandler? requestHandler, + AuthRequestHandler? requestHandler, + FirebaseTokenVerifier? idTokenVerifier, + FirebaseTokenVerifier? sessionCookieVerifier, }) { return app.getOrInitService( FirebaseServiceType.auth.name, - (app) => Auth._(app, requestHandler: requestHandler), + (app) => Auth._internal( + app, + requestHandler: requestHandler, + idTokenVerifier: idTokenVerifier, + sessionCookieVerifier: sessionCookieVerifier, + ), ); } - Auth._(FirebaseApp app, {@internal AuthRequestHandler? requestHandler}) - : super( - app: app, - authRequestHandler: requestHandler ?? AuthRequestHandler(app), - ); + Auth._internal( + FirebaseApp app, { + AuthRequestHandler? requestHandler, + super.idTokenVerifier, + super.sessionCookieVerifier, + }) : super( + app: app, + authRequestHandler: requestHandler ?? AuthRequestHandler(app), + ); @override Future delete() async { diff --git a/packages/dart_firebase_admin/lib/src/auth/auth_config.dart b/packages/dart_firebase_admin/lib/src/auth/auth_config.dart index aa2be2dc..adcf37d0 100644 --- a/packages/dart_firebase_admin/lib/src/auth/auth_config.dart +++ b/packages/dart_firebase_admin/lib/src/auth/auth_config.dart @@ -215,6 +215,44 @@ class SAMLAuthProviderConfig extends AuthProviderConfig this.enableRequestSigning, }) : super._(); + factory SAMLAuthProviderConfig.fromResponse( + v2.GoogleCloudIdentitytoolkitAdminV2InboundSamlConfig response, + ) { + final idpConfig = response.idpConfig; + final idpEntityId = idpConfig?.idpEntityId; + final ssoURL = idpConfig?.ssoUrl; + final spConfig = response.spConfig; + final spEntityId = spConfig?.spEntityId; + final providerId = response.name.let( + SAMLAuthProviderConfig.getProviderIdFromResourceName, + ); + + if (idpConfig == null || + idpEntityId == null || + ssoURL == null || + spConfig == null || + spEntityId == null || + providerId == null) { + throw FirebaseAuthAdminException( + AuthClientErrorCode.internalError, + 'INTERNAL ASSERT FAILED: Invalid SAML configuration response', + ); + } + + return SAMLAuthProviderConfig( + idpEntityId: idpEntityId, + ssoURL: ssoURL, + x509Certificates: [ + ...?idpConfig.idpCertificates?.map((c) => c.x509Certificate).nonNulls, + ], + rpEntityId: spEntityId, + callbackURL: spConfig.callbackUri, + providerId: providerId, + displayName: response.displayName, + enabled: response.enabled ?? false, + ); + } + /// The SAML IdP entity identifier. @override final String idpEntityId; @@ -252,6 +290,134 @@ class SAMLAuthProviderConfig extends AuthProviderConfig @override final String? issuer; + + static v2.GoogleCloudIdentitytoolkitAdminV2InboundSamlConfig? + _buildServerRequest( + _SAMLAuthProviderRequestBase options, { + bool ignoreMissingFields = false, + }) { + final makeRequest = options.providerId != null || ignoreMissingFields; + if (!makeRequest) return null; + + SAMLAuthProviderConfig._validate( + options, + ignoreMissingFields: ignoreMissingFields, + ); + + return v2.GoogleCloudIdentitytoolkitAdminV2InboundSamlConfig( + enabled: options.enabled, + displayName: options.displayName, + spConfig: options.callbackURL == null && options.rpEntityId == null + ? null + : v2.GoogleCloudIdentitytoolkitAdminV2SpConfig( + callbackUri: options.callbackURL, + spEntityId: options.rpEntityId, + ), + idpConfig: + options.idpEntityId == null && + options.ssoURL == null && + options.x509Certificates == null + ? null + : v2.GoogleCloudIdentitytoolkitAdminV2IdpConfig( + idpEntityId: options.idpEntityId, + ssoUrl: options.ssoURL, + signRequest: options.enableRequestSigning, + idpCertificates: options.x509Certificates + ?.map( + (c) => v2.GoogleCloudIdentitytoolkitAdminV2IdpCertificate( + x509Certificate: c, + ), + ) + .toList(), + ), + ); + } + + static String? getProviderIdFromResourceName(String resourceName) { + // name is of form projects/project1/inboundSamlConfigs/providerId1 + final matchProviderRes = RegExp( + r'\/inboundSamlConfigs\/(saml\..*)$', + ).firstMatch(resourceName); + if (matchProviderRes == null || matchProviderRes.groupCount < 1) { + return null; + } + return matchProviderRes[1]; + } + + static bool isProviderId(String providerId) { + return providerId.isNotEmpty && providerId.startsWith('saml.'); + } + + static void _validate( + _SAMLAuthProviderRequestBase options, { + required bool ignoreMissingFields, + }) { + // Required fields. + final providerId = options.providerId; + if (providerId != null && providerId.isNotEmpty) { + if (!providerId.startsWith('saml.')) { + throw FirebaseAuthAdminException( + AuthClientErrorCode.invalidProviderId, + '"SAMLAuthProviderConfig.providerId" must be a valid non-empty string prefixed with "saml.".', + ); + } + } else if (!ignoreMissingFields) { + // providerId is required and not provided correctly. + throw FirebaseAuthAdminException( + providerId == null + ? AuthClientErrorCode.missingProviderId + : AuthClientErrorCode.invalidProviderId, + '"SAMLAuthProviderConfig.providerId" must be a valid non-empty string prefixed with "saml.".', + ); + } + + final idpEntityId = options.idpEntityId; + if (!(ignoreMissingFields && idpEntityId == null) && + !(idpEntityId != null && idpEntityId.isNotEmpty)) { + throw FirebaseAuthAdminException( + AuthClientErrorCode.invalidConfig, + '"SAMLAuthProviderConfig.idpEntityId" must be a valid non-empty string.', + ); + } + + final ssoURL = options.ssoURL; + if (!(ignoreMissingFields && ssoURL == null) && + Uri.tryParse(ssoURL ?? '') == null) { + throw FirebaseAuthAdminException( + AuthClientErrorCode.invalidConfig, + '"SAMLAuthProviderConfig.ssoURL" must be a valid URL string.', + ); + } + + final rpEntityId = options.rpEntityId; + if (!(ignoreMissingFields && rpEntityId == null) && + !(rpEntityId != null && rpEntityId.isNotEmpty)) { + throw FirebaseAuthAdminException( + rpEntityId == null + ? AuthClientErrorCode.missingSamlRelyingPartyConfig + : AuthClientErrorCode.invalidConfig, + '"SAMLAuthProviderConfig.rpEntityId" must be a valid non-empty string.', + ); + } + + final callbackURL = options.callbackURL; + if (!(ignoreMissingFields && callbackURL == null) && + (callbackURL != null && Uri.tryParse(callbackURL) == null)) { + throw FirebaseAuthAdminException( + AuthClientErrorCode.invalidConfig, + '"SAMLAuthProviderConfig.callbackURL" must be a valid URL string.', + ); + } + + final x509Certificates = options.x509Certificates; + if (!(ignoreMissingFields && x509Certificates == null) && + x509Certificates == null) { + throw FirebaseAuthAdminException( + AuthClientErrorCode.invalidConfig, + '"SAMLAuthProviderConfig.x509Certificates" must be a valid array of X509 certificate strings.', + ); + } + } } /// The [OIDC](https://openid.net/specs/openid-connect-core-1_0-final.html) Auth @@ -269,68 +435,7 @@ class OIDCAuthProviderConfig extends AuthProviderConfig this.responseType, }) : super._(); - /// This is the required client ID used to confirm the audience of an OIDC - /// provider's - /// [ID token](https://openid.net/specs/openid-connect-core-1_0-final.html#IDToken). - @override - final String clientId; - - /// This is the required provider issuer used to match the provider issuer of - /// the ID token and to determine the corresponding OIDC discovery document, eg. - /// [`/.well-known/openid-configuration`](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig). - /// This is needed for the following: - /// - /// ID token validation will be performed as defined in the - /// [spec](https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation). - @override - final String issuer; - - /// The OIDC provider's client secret to enable OIDC code flow. - @override - final String? clientSecret; - - /// The OIDC provider's response object for OAuth authorization flow. - @override - final OAuthResponseType? responseType; -} - -/// The interface representing OIDC provider's response object for OAuth -/// authorization flow. -/// One of the following settings is required: -/// -class OAuthResponseType { - OAuthResponseType._({required this.idToken, required this.code}); - - /// Whether ID token is returned from IdP's authorization endpoint. - final bool? idToken; - - /// Whether authorization code is returned from IdP's authorization endpoint. - final bool? code; -} - -class _OIDCConfig extends OIDCAuthProviderConfig { - _OIDCConfig({ - required super.providerId, - required super.displayName, - required super.enabled, - required super.clientId, - required super.issuer, - required super.clientSecret, - required super.responseType, - }); - - factory _OIDCConfig.fromResponse( + factory OIDCAuthProviderConfig.fromResponse( v2.GoogleCloudIdentitytoolkitAdminV2OAuthIdpConfig response, ) { final issuer = response.issuer; @@ -343,7 +448,9 @@ class _OIDCConfig extends OIDCAuthProviderConfig { ); } - final providerId = _OIDCConfig.getProviderIdFromResourceName(name); + final providerId = OIDCAuthProviderConfig.getProviderIdFromResourceName( + name, + ); if (providerId == null) { throw FirebaseAuthAdminException( AuthClientErrorCode.internalError, @@ -351,7 +458,7 @@ class _OIDCConfig extends OIDCAuthProviderConfig { ); } - return _OIDCConfig( + return OIDCAuthProviderConfig( providerId: providerId, displayName: response.displayName, enabled: response.enabled ?? false, @@ -367,7 +474,39 @@ class _OIDCConfig extends OIDCAuthProviderConfig { ); } - static void validate( + /// This is the required client ID used to confirm the audience of an OIDC + /// provider's + /// [ID token](https://openid.net/specs/openid-connect-core-1_0-final.html#IDToken). + @override + final String clientId; + + /// This is the required provider issuer used to match the provider issuer of + /// the ID token and to determine the corresponding OIDC discovery document, eg. + /// [`/.well-known/openid-configuration`](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig). + /// This is needed for the following: + /// + /// ID token validation will be performed as defined in the + /// [spec](https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation). + @override + final String issuer; + + /// The OIDC provider's client secret to enable OIDC code flow. + @override + final String? clientSecret; + + /// The OIDC provider's response object for OAuth authorization flow. + @override + final OAuthResponseType? responseType; + + static void _validate( _OIDCAuthProviderRequestBase options, { required bool ignoreMissingFields, }) { @@ -439,14 +578,18 @@ class _OIDCConfig extends OIDCAuthProviderConfig { } } - static v2.GoogleCloudIdentitytoolkitAdminV2OAuthIdpConfig? buildServerRequest( + static v2.GoogleCloudIdentitytoolkitAdminV2OAuthIdpConfig? + _buildServerRequest( _OIDCAuthProviderRequestBase options, { bool ignoreMissingFields = false, }) { final makeRequest = options.providerId != null || ignoreMissingFields; if (!makeRequest) return null; - _OIDCConfig.validate(options, ignoreMissingFields: ignoreMissingFields); + OIDCAuthProviderConfig._validate( + options, + ignoreMissingFields: ignoreMissingFields, + ); return v2.GoogleCloudIdentitytoolkitAdminV2OAuthIdpConfig( enabled: options.enabled, @@ -469,7 +612,7 @@ class _OIDCConfig extends OIDCAuthProviderConfig { final matchProviderRes = RegExp( r'\/oauthIdpConfigs\/(oidc\..*)$', ).firstMatch(resourceName); - if (matchProviderRes == null || matchProviderRes.groupCount < 2) { + if (matchProviderRes == null || matchProviderRes.groupCount < 1) { return null; } return matchProviderRes[1]; @@ -480,188 +623,21 @@ class _OIDCConfig extends OIDCAuthProviderConfig { } } -class _SAMLConfig extends SAMLAuthProviderConfig { - _SAMLConfig({ - required super.idpEntityId, - required super.ssoURL, - required super.x509Certificates, - required super.rpEntityId, - required super.callbackURL, - required super.providerId, - required super.displayName, - required super.enabled, - }); - - factory _SAMLConfig.fromResponse( - v2.GoogleCloudIdentitytoolkitAdminV2InboundSamlConfig response, - ) { - final idpConfig = response.idpConfig; - final idpEntityId = idpConfig?.idpEntityId; - final ssoURL = idpConfig?.ssoUrl; - final spConfig = response.spConfig; - final spEntityId = spConfig?.spEntityId; - final providerId = response.name.let( - _SAMLConfig.getProviderIdFromResourceName, - ); - - if (idpConfig == null || - idpEntityId == null || - ssoURL == null || - spConfig == null || - spEntityId == null || - providerId == null) { - throw FirebaseAuthAdminException( - AuthClientErrorCode.internalError, - 'INTERNAL ASSERT FAILED: Invalid SAML configuration response', - ); - } - - return _SAMLConfig( - idpEntityId: idpEntityId, - ssoURL: ssoURL, - x509Certificates: [ - ...?idpConfig.idpCertificates?.map((c) => c.x509Certificate).nonNulls, - ], - rpEntityId: spEntityId, - callbackURL: spConfig.callbackUri, - providerId: providerId, - displayName: response.displayName, - enabled: response.enabled ?? false, - ); - } - - static v2.GoogleCloudIdentitytoolkitAdminV2InboundSamlConfig? - buildServerRequest( - _SAMLAuthProviderRequestBase options, { - bool ignoreMissingFields = false, - }) { - final makeRequest = options.providerId != null || ignoreMissingFields; - if (!makeRequest) return null; - - _SAMLConfig.validate(options, ignoreMissingFields: ignoreMissingFields); - - return v2.GoogleCloudIdentitytoolkitAdminV2InboundSamlConfig( - enabled: options.enabled, - displayName: options.displayName, - spConfig: options.callbackURL == null && options.rpEntityId == null - ? null - : v2.GoogleCloudIdentitytoolkitAdminV2SpConfig( - callbackUri: options.callbackURL, - spEntityId: options.rpEntityId, - ), - idpConfig: - options.idpEntityId == null && - options.ssoURL == null && - options.x509Certificates == null - ? null - : v2.GoogleCloudIdentitytoolkitAdminV2IdpConfig( - idpEntityId: options.idpEntityId, - ssoUrl: options.ssoURL, - signRequest: options.enableRequestSigning, - idpCertificates: options.x509Certificates - ?.map( - (c) => v2.GoogleCloudIdentitytoolkitAdminV2IdpCertificate( - x509Certificate: c, - ), - ) - .toList(), - ), - ); - } - - static String? getProviderIdFromResourceName(String resourceName) { - // name is of form projects/project1/inboundSamlConfigs/providerId1 - final matchProviderRes = RegExp( - r'\/inboundSamlConfigs\/(saml\..*)$', - ).firstMatch(resourceName); - if (matchProviderRes == null || matchProviderRes.groupCount < 2) { - return null; - } - return matchProviderRes[1]; - } - - static bool isProviderId(String providerId) { - return providerId.isNotEmpty && providerId.startsWith('saml.'); - } - - static void validate( - _SAMLAuthProviderRequestBase options, { - required bool ignoreMissingFields, - }) { - // Required fields. - final providerId = options.providerId; - if (providerId != null && providerId.isNotEmpty) { - if (providerId.startsWith('saml.')) { - throw FirebaseAuthAdminException( - AuthClientErrorCode.invalidProviderId, - '"SAMLAuthProviderConfig.providerId" must be a valid non-empty string prefixed with "saml.".', - ); - } - } else if (!ignoreMissingFields) { - // providerId is required and not provided correctly. - throw FirebaseAuthAdminException( - providerId == null - ? AuthClientErrorCode.missingProviderId - : AuthClientErrorCode.invalidProviderId, - '"SAMLAuthProviderConfig.providerId" must be a valid non-empty string prefixed with "saml.".', - ); - } - - final idpEntityId = options.idpEntityId; - if (!(ignoreMissingFields && idpEntityId == null) && - !(idpEntityId != null && idpEntityId.isNotEmpty)) { - throw FirebaseAuthAdminException( - AuthClientErrorCode.invalidConfig, - '"SAMLAuthProviderConfig.idpEntityId" must be a valid non-empty string.', - ); - } - - final ssoURL = options.ssoURL; - if (!(ignoreMissingFields && ssoURL == null) && - Uri.tryParse(ssoURL ?? '') == null) { - throw FirebaseAuthAdminException( - AuthClientErrorCode.invalidConfig, - '"SAMLAuthProviderConfig.ssoURL" must be a valid URL string.', - ); - } - - final rpEntityId = options.rpEntityId; - if (!(ignoreMissingFields && rpEntityId == null) && - !(rpEntityId != null && rpEntityId.isNotEmpty)) { - throw FirebaseAuthAdminException( - rpEntityId != null - ? AuthClientErrorCode.missingSamlRelyingPartyConfig - : AuthClientErrorCode.invalidConfig, - '"SAMLAuthProviderConfig.rpEntityId" must be a valid non-empty string.', - ); - } +/// The interface representing OIDC provider's response object for OAuth +/// authorization flow. +/// One of the following settings is required: +/// +class OAuthResponseType { + OAuthResponseType._({required this.idToken, required this.code}); - final callbackURL = options.callbackURL; - if (!(ignoreMissingFields && callbackURL == null) && - (callbackURL != null && Uri.tryParse(callbackURL) == null)) { - throw FirebaseAuthAdminException( - AuthClientErrorCode.invalidConfig, - '"SAMLAuthProviderConfig.callbackURL" must be a valid URL string.', - ); - } + /// Whether ID token is returned from IdP's authorization endpoint. + final bool? idToken; - final x509Certificates = options.x509Certificates; - if (!(ignoreMissingFields && x509Certificates == null) && - x509Certificates == null) { - throw FirebaseAuthAdminException( - AuthClientErrorCode.invalidConfig, - '"SAMLAuthProviderConfig.x509Certificates" must be a valid array of X509 certificate strings.', - ); - } - for (final cert in x509Certificates ?? const []) { - if (cert.isEmpty) { - throw FirebaseAuthAdminException( - AuthClientErrorCode.invalidConfig, - '"SAMLAuthProviderConfig.x509Certificates" must be a valid array of X509 certificate strings.', - ); - } - } - } + /// Whether authorization code is returned from IdP's authorization endpoint. + final bool? code; } const _sentinel = _Sentinel(); @@ -903,16 +879,6 @@ class CreatePhoneMultiFactorInfoRequest extends CreateMultiFactorInfoRequest { /// The phone number associated with a phone second factor. final String phoneNumber; - - @override - v1.GoogleCloudIdentitytoolkitV1MfaFactor - toGoogleCloudIdentitytoolkitV1MfaFactor() { - return v1.GoogleCloudIdentitytoolkitV1MfaFactor( - displayName: displayName, - // TODO param is optional, but phoneNumber is required. - phoneInfo: phoneNumber, - ); - } } /// Interface representing base properties of a user-enrolled second factor for a @@ -924,7 +890,15 @@ sealed class CreateMultiFactorInfoRequest { final String? displayName; v1.GoogleCloudIdentitytoolkitV1MfaFactor - toGoogleCloudIdentitytoolkitV1MfaFactor(); + toGoogleCloudIdentitytoolkitV1MfaFactor() { + return switch (this) { + CreatePhoneMultiFactorInfoRequest(:final phoneNumber) => + v1.GoogleCloudIdentitytoolkitV1MfaFactor( + displayName: displayName, + phoneInfo: phoneNumber, + ), + }; + } } /// Interface representing a phone specific user-enrolled second factor @@ -970,14 +944,13 @@ sealed class UpdateMultiFactorInfoRequest { final DateTime? enrollmentTime; v1.GoogleCloudIdentitytoolkitV1MfaEnrollment toMfaEnrollment() { - final that = this; - return switch (that) { - UpdatePhoneMultiFactorInfoRequest() => + return switch (this) { + UpdatePhoneMultiFactorInfoRequest(:final phoneNumber) => v1.GoogleCloudIdentitytoolkitV1MfaEnrollment( mfaEnrollmentId: uid, displayName: displayName, // Required for all phone second factors. - phoneInfo: that.phoneNumber, + phoneInfo: phoneNumber, enrolledAt: enrollmentTime?.toUtc().toIso8601String(), ), }; diff --git a/packages/dart_firebase_admin/lib/src/auth/auth_config_tenant.dart b/packages/dart_firebase_admin/lib/src/auth/auth_config_tenant.dart index f71bf521..950ec162 100644 --- a/packages/dart_firebase_admin/lib/src/auth/auth_config_tenant.dart +++ b/packages/dart_firebase_admin/lib/src/auth/auth_config_tenant.dart @@ -111,26 +111,93 @@ enum MultiFactorConfigState { } } +/// Interface representing configuration settings for TOTP second factor auth. +class TotpMultiFactorProviderConfig { + /// Creates a new [TotpMultiFactorProviderConfig] instance. + TotpMultiFactorProviderConfig({this.adjacentIntervals}) { + final intervals = adjacentIntervals; + if (intervals != null && (intervals < 0 || intervals > 10)) { + throw FirebaseAuthAdminException( + AuthClientErrorCode.invalidArgument, + '"adjacentIntervals" must be a valid number between 0 and 10 (both inclusive).', + ); + } + } + + /// The allowed number of adjacent intervals that will be used for verification + /// to compensate for clock skew. Valid range is 0-10 (inclusive). + final int? adjacentIntervals; + + Map toJson() { + return { + if (adjacentIntervals != null) 'adjacentIntervals': adjacentIntervals, + }; + } +} + +/// Interface representing a multi-factor auth provider configuration. +/// This interface is used for second factor auth providers other than SMS. +/// Currently, only TOTP is supported. +class MultiFactorProviderConfig { + /// Creates a new [MultiFactorProviderConfig] instance. + MultiFactorProviderConfig({required this.state, this.totpProviderConfig}) { + // Since TOTP is the only provider config available right now, it must be defined + if (totpProviderConfig == null) { + throw FirebaseAuthAdminException( + AuthClientErrorCode.invalidConfig, + '"totpProviderConfig" must be defined.', + ); + } + } + + /// Indicates whether this multi-factor provider is enabled or disabled. + final MultiFactorConfigState state; + + /// TOTP multi-factor provider config. + final TotpMultiFactorProviderConfig? totpProviderConfig; + + Map toJson() { + return { + 'state': state.value, + if (totpProviderConfig != null) + 'totpProviderConfig': totpProviderConfig!.toJson(), + }; + } +} + /// Interface representing a multi-factor configuration. class MultiFactorConfig { - MultiFactorConfig({required this.state, this.factorIds}); + MultiFactorConfig({ + required this.state, + this.factorIds, + this.providerConfigs, + }); /// The multi-factor config state. final MultiFactorConfigState state; /// The list of identifiers for enabled second factors. - /// Currently only 'phone' is supported. + /// Currently 'phone' and 'totp' are supported. final List? factorIds; + /// The configuration for multi-factor auth providers. + final List? providerConfigs; + Map toJson() => { 'state': state.value, if (factorIds != null) 'factorIds': factorIds, + if (providerConfigs != null) + 'providerConfigs': providerConfigs!.map((e) => e.toJson()).toList(), }; } /// Internal class for multi-factor authentication configuration. class _MultiFactorAuthConfig implements MultiFactorConfig { - _MultiFactorAuthConfig({required this.state, this.factorIds}); + _MultiFactorAuthConfig({ + required this.state, + this.factorIds, + this.providerConfigs, + }); factory _MultiFactorAuthConfig.fromServerResponse( Map response, @@ -155,9 +222,38 @@ class _MultiFactorAuthConfig implements MultiFactorConfig { } } + // Parse provider configs + final providerConfigsData = response['providerConfigs'] as List?; + final providerConfigs = []; + + if (providerConfigsData != null) { + for (final configData in providerConfigsData) { + if (configData is! Map) continue; + + final configState = configData['state'] as String?; + if (configState == null) continue; + + final totpConfigData = + configData['totpProviderConfig'] as Map?; + if (totpConfigData != null) { + final adjacentIntervals = totpConfigData['adjacentIntervals'] as int?; + providerConfigs.add( + MultiFactorProviderConfig( + state: MultiFactorConfigState.fromString(configState), + totpProviderConfig: TotpMultiFactorProviderConfig( + adjacentIntervals: adjacentIntervals, + ), + ), + ); + } + } + } + return _MultiFactorAuthConfig( state: MultiFactorConfigState.fromString(stateValue as String), factorIds: factorIds.isEmpty ? null : factorIds, + providerConfigs: + providerConfigs, // Always return list, never null (matches Node.js SDK) ); } @@ -177,6 +273,26 @@ class _MultiFactorAuthConfig implements MultiFactorConfig { request['enabledProviders'] = enabledProviders; } + // Build provider configs + if (options.providerConfigs != null) { + final providerConfigsData = >[]; + for (final config in options.providerConfigs!) { + final configData = {'state': config.state.value}; + + if (config.totpProviderConfig != null) { + final totpData = {}; + if (config.totpProviderConfig!.adjacentIntervals != null) { + totpData['adjacentIntervals'] = + config.totpProviderConfig!.adjacentIntervals; + } + configData['totpProviderConfig'] = totpData; + } + + providerConfigsData.add(configData); + } + request['providerConfigs'] = providerConfigsData; + } + return request; } @@ -186,10 +302,15 @@ class _MultiFactorAuthConfig implements MultiFactorConfig { @override final List? factorIds; + @override + final List? providerConfigs; + @override Map toJson() => { 'state': state.value, if (factorIds != null) 'factorIds': factorIds, + if (providerConfigs != null) + 'providerConfigs': providerConfigs!.map((e) => e.toJson()).toList(), }; } @@ -257,6 +378,87 @@ enum RecaptchaProviderEnforcementState { } } +/// The actions to take for reCAPTCHA-protected requests. +enum RecaptchaAction { + block('BLOCK'); + + const RecaptchaAction(this.value); + final String value; + + static RecaptchaAction fromString(String value) { + return RecaptchaAction.values.firstWhere( + (e) => e.value == value, + orElse: () => RecaptchaAction.block, + ); + } +} + +/// The key's platform type. +enum RecaptchaKeyClientType { + web('WEB'), + ios('IOS'), + android('ANDROID'); + + const RecaptchaKeyClientType(this.value); + final String value; + + static RecaptchaKeyClientType fromString(String value) { + return RecaptchaKeyClientType.values.firstWhere( + (e) => e.value == value, + orElse: () => RecaptchaKeyClientType.web, + ); + } +} + +/// The config for a reCAPTCHA action rule. +class RecaptchaManagedRule { + const RecaptchaManagedRule({required this.endScore, this.action}); + + /// The action will be enforced if the reCAPTCHA score of a request is larger than endScore. + final double endScore; + + /// The action for reCAPTCHA-protected requests. + final RecaptchaAction? action; + + Map toJson() => { + 'endScore': endScore, + if (action != null) 'action': action!.value, + }; +} + +/// The managed rules for toll fraud provider, containing the enforcement status. +/// The toll fraud provider contains all SMS related user flows. +class RecaptchaTollFraudManagedRule { + const RecaptchaTollFraudManagedRule({required this.startScore, this.action}); + + /// The action will be enforced if the reCAPTCHA score of a request is larger than startScore. + final double startScore; + + /// The action for reCAPTCHA-protected requests. + final RecaptchaAction? action; + + Map toJson() => { + 'startScore': startScore, + if (action != null) 'action': action!.value, + }; +} + +/// The reCAPTCHA key config. +class RecaptchaKey { + const RecaptchaKey({required this.key, this.type}); + + /// The reCAPTCHA site key. + final String key; + + /// The key's client platform type. + final RecaptchaKeyClientType? type; + + Map toJson() => { + 'key': key, + if (type != null) 'type': type!.value, + }; +} + /// The request interface for updating a reCAPTCHA Config. /// By enabling reCAPTCHA Enterprise Integration you are /// agreeing to reCAPTCHA Enterprise @@ -265,7 +467,12 @@ class RecaptchaConfig { RecaptchaConfig({ this.emailPasswordEnforcementState, this.phoneEnforcementState, + this.managedRules, + this.recaptchaKeys, this.useAccountDefender, + this.useSmsBotScore, + this.useSmsTollFraudProtection, + this.smsTollFraudManagedRules, }); /// The enforcement state of the email password provider. @@ -274,15 +481,44 @@ class RecaptchaConfig { /// The enforcement state of the phone provider. final RecaptchaProviderEnforcementState? phoneEnforcementState; + /// The reCAPTCHA managed rules. + final List? managedRules; + + /// The reCAPTCHA keys. + final List? recaptchaKeys; + /// Whether to use account defender for reCAPTCHA assessment. final bool? useAccountDefender; + /// Whether to use the rCE bot score for reCAPTCHA phone provider. + /// Can only be true when the phone_enforcement_state is AUDIT or ENFORCE. + final bool? useSmsBotScore; + + /// Whether to use the rCE SMS toll fraud protection risk score for reCAPTCHA phone provider. + /// Can only be true when the phone_enforcement_state is AUDIT or ENFORCE. + final bool? useSmsTollFraudProtection; + + /// The managed rules for toll fraud provider, containing the enforcement status. + /// The toll fraud provider contains all SMS related user flows. + final List? smsTollFraudManagedRules; + Map toJson() => { if (emailPasswordEnforcementState != null) 'emailPasswordEnforcementState': emailPasswordEnforcementState!.value, if (phoneEnforcementState != null) 'phoneEnforcementState': phoneEnforcementState!.value, + if (managedRules != null) + 'managedRules': managedRules!.map((e) => e.toJson()).toList(), + if (recaptchaKeys != null) + 'recaptchaKeys': recaptchaKeys!.map((e) => e.toJson()).toList(), if (useAccountDefender != null) 'useAccountDefender': useAccountDefender, + if (useSmsBotScore != null) 'useSmsBotScore': useSmsBotScore, + if (useSmsTollFraudProtection != null) + 'useSmsTollFraudProtection': useSmsTollFraudProtection, + if (smsTollFraudManagedRules != null) + 'smsTollFraudManagedRules': smsTollFraudManagedRules! + .map((e) => e.toJson()) + .toList(), }; } @@ -291,12 +527,63 @@ class _RecaptchaAuthConfig implements RecaptchaConfig { _RecaptchaAuthConfig({ this.emailPasswordEnforcementState, this.phoneEnforcementState, + this.managedRules, + this.recaptchaKeys, this.useAccountDefender, + this.useSmsBotScore, + this.useSmsTollFraudProtection, + this.smsTollFraudManagedRules, }); factory _RecaptchaAuthConfig.fromServerResponse( Map response, ) { + List? managedRules; + if (response['managedRules'] != null) { + final rulesList = response['managedRules'] as List; + managedRules = rulesList.map((rule) { + final ruleMap = rule as Map; + return RecaptchaManagedRule( + endScore: (ruleMap['endScore'] as num).toDouble(), + action: ruleMap['action'] != null + ? RecaptchaAction.fromString(ruleMap['action'] as String) + : null, + ); + }).toList(); + } + + List? recaptchaKeys; + if (response['recaptchaKeys'] != null) { + final keysList = response['recaptchaKeys'] as List; + recaptchaKeys = keysList.map((key) { + final keyMap = key as Map; + return RecaptchaKey( + key: keyMap['key'] as String, + type: keyMap['type'] != null + ? RecaptchaKeyClientType.fromString(keyMap['type'] as String) + : null, + ); + }).toList(); + } + + List? smsTollFraudManagedRules; + // Server response uses 'tollFraudManagedRules' but client uses 'smsTollFraudManagedRules' + final tollFraudRules = + response['tollFraudManagedRules'] ?? + response['smsTollFraudManagedRules']; + if (tollFraudRules != null) { + final rulesList = tollFraudRules as List; + smsTollFraudManagedRules = rulesList.map((rule) { + final ruleMap = rule as Map; + return RecaptchaTollFraudManagedRule( + startScore: (ruleMap['startScore'] as num).toDouble(), + action: ruleMap['action'] != null + ? RecaptchaAction.fromString(ruleMap['action'] as String) + : null, + ); + }).toList(); + } + return _RecaptchaAuthConfig( emailPasswordEnforcementState: response['emailPasswordEnforcementState'] != null @@ -309,11 +596,18 @@ class _RecaptchaAuthConfig implements RecaptchaConfig { response['phoneEnforcementState'] as String, ) : null, + managedRules: managedRules, + recaptchaKeys: recaptchaKeys, useAccountDefender: response['useAccountDefender'] as bool?, + useSmsBotScore: response['useSmsBotScore'] as bool?, + useSmsTollFraudProtection: response['useSmsTollFraudProtection'] as bool?, + smsTollFraudManagedRules: smsTollFraudManagedRules, ); } static Map buildServerRequest(RecaptchaConfig options) { + _validate(options); + final request = {}; if (options.emailPasswordEnforcementState != null) { @@ -323,29 +617,110 @@ class _RecaptchaAuthConfig implements RecaptchaConfig { if (options.phoneEnforcementState != null) { request['phoneEnforcementState'] = options.phoneEnforcementState!.value; } + if (options.managedRules != null) { + request['managedRules'] = options.managedRules! + .map((e) => e.toJson()) + .toList(); + } + if (options.recaptchaKeys != null) { + request['recaptchaKeys'] = options.recaptchaKeys! + .map((e) => e.toJson()) + .toList(); + } if (options.useAccountDefender != null) { request['useAccountDefender'] = options.useAccountDefender; } + if (options.useSmsBotScore != null) { + request['useSmsBotScore'] = options.useSmsBotScore; + } + if (options.useSmsTollFraudProtection != null) { + request['useSmsTollFraudProtection'] = options.useSmsTollFraudProtection; + } + // Server expects 'tollFraudManagedRules' but client uses 'smsTollFraudManagedRules' + if (options.smsTollFraudManagedRules != null) { + request['tollFraudManagedRules'] = options.smsTollFraudManagedRules! + .map((e) => e.toJson()) + .toList(); + } return request; } + static void _validate(RecaptchaConfig options) { + if (options.managedRules != null) { + options.managedRules!.forEach(_validateManagedRule); + } + + // Note: In Dart, bool? is already type-checked at compile time, so we don't need runtime validation + // But we keep the validation structure for consistency with Node.js SDK + + if (options.smsTollFraudManagedRules != null) { + options.smsTollFraudManagedRules!.forEach(_validateTollFraudManagedRule); + } + } + + static void _validateManagedRule(RecaptchaManagedRule rule) { + if (rule.action != null && rule.action != RecaptchaAction.block) { + throw FirebaseAuthAdminException( + AuthClientErrorCode.invalidConfig, + '"RecaptchaManagedRule.action" must be "BLOCK".', + ); + } + } + + static void _validateTollFraudManagedRule( + RecaptchaTollFraudManagedRule rule, + ) { + if (rule.action != null && rule.action != RecaptchaAction.block) { + throw FirebaseAuthAdminException( + AuthClientErrorCode.invalidConfig, + '"RecaptchaTollFraudManagedRule.action" must be "BLOCK".', + ); + } + } + @override final RecaptchaProviderEnforcementState? emailPasswordEnforcementState; @override final RecaptchaProviderEnforcementState? phoneEnforcementState; + @override + final List? managedRules; + + @override + final List? recaptchaKeys; + @override final bool? useAccountDefender; + @override + final bool? useSmsBotScore; + + @override + final bool? useSmsTollFraudProtection; + + @override + final List? smsTollFraudManagedRules; + @override Map toJson() => { if (emailPasswordEnforcementState != null) 'emailPasswordEnforcementState': emailPasswordEnforcementState!.value, if (phoneEnforcementState != null) 'phoneEnforcementState': phoneEnforcementState!.value, + if (managedRules != null) + 'managedRules': managedRules!.map((e) => e.toJson()).toList(), + if (recaptchaKeys != null) + 'recaptchaKeys': recaptchaKeys!.map((e) => e.toJson()).toList(), if (useAccountDefender != null) 'useAccountDefender': useAccountDefender, + if (useSmsBotScore != null) 'useSmsBotScore': useSmsBotScore, + if (useSmsTollFraudProtection != null) + 'useSmsTollFraudProtection': useSmsTollFraudProtection, + if (smsTollFraudManagedRules != null) + 'smsTollFraudManagedRules': smsTollFraudManagedRules! + .map((e) => e.toJson()) + .toList(), }; } diff --git a/packages/dart_firebase_admin/lib/src/auth/auth_http_client.dart b/packages/dart_firebase_admin/lib/src/auth/auth_http_client.dart index 0672993d..2ebed546 100644 --- a/packages/dart_firebase_admin/lib/src/auth/auth_http_client.dart +++ b/packages/dart_firebase_admin/lib/src/auth/auth_http_client.dart @@ -151,11 +151,13 @@ class AuthHttpClient { Future createOAuthIdpConfig( auth2.GoogleCloudIdentitytoolkitAdminV2OAuthIdpConfig request, + String providerId, ) { return v2((client, projectId) async { final response = await client.projects.oauthIdpConfigs.create( request, buildParent(projectId), + oauthIdpConfigId: providerId, ); final name = response.name; @@ -173,11 +175,13 @@ class AuthHttpClient { Future createInboundSamlConfig( auth2.GoogleCloudIdentitytoolkitAdminV2InboundSamlConfig request, + String providerId, ) { return v2((client, projectId) async { final response = await client.projects.inboundSamlConfigs.create( request, buildParent(projectId), + inboundSamlConfigId: providerId, ); final name = response.name; diff --git a/packages/dart_firebase_admin/lib/src/auth/auth_request_handler.dart b/packages/dart_firebase_admin/lib/src/auth/auth_request_handler.dart index 574b08ea..094a9c11 100644 --- a/packages/dart_firebase_admin/lib/src/auth/auth_request_handler.dart +++ b/packages/dart_firebase_admin/lib/src/auth/auth_request_handler.dart @@ -115,14 +115,17 @@ abstract class _AbstractAuthRequestHandler { Future createOAuthIdpConfig(OIDCAuthProviderConfig options) async { final request = - _OIDCConfig.buildServerRequest(options) ?? + OIDCAuthProviderConfig._buildServerRequest(options) ?? auth2.GoogleCloudIdentitytoolkitAdminV2OAuthIdpConfig(); - final response = await _httpClient.createOAuthIdpConfig(request); + final response = await _httpClient.createOAuthIdpConfig( + request, + options.providerId, + ); final name = response.name; if (name == null || - _OIDCConfig.getProviderIdFromResourceName(name) == null) { + OIDCAuthProviderConfig.getProviderIdFromResourceName(name) == null) { throw FirebaseAuthAdminException( AuthClientErrorCode.internalError, 'INTERNAL ASSERT FAILED: Unable to create OIDC configuration', @@ -136,14 +139,17 @@ abstract class _AbstractAuthRequestHandler { Future createInboundSamlConfig(SAMLAuthProviderConfig options) async { final request = - _SAMLConfig.buildServerRequest(options) ?? + SAMLAuthProviderConfig._buildServerRequest(options) ?? auth2.GoogleCloudIdentitytoolkitAdminV2InboundSamlConfig(); - final response = await _httpClient.createInboundSamlConfig(request); + final response = await _httpClient.createInboundSamlConfig( + request, + options.providerId, + ); final name = response.name; if (name == null || - _SAMLConfig.getProviderIdFromResourceName(name) == null) { + SAMLAuthProviderConfig.getProviderIdFromResourceName(name) == null) { throw FirebaseAuthAdminException( AuthClientErrorCode.internalError, 'INTERNAL ASSERT FAILED: Unable to create SAML configuration', @@ -204,11 +210,11 @@ abstract class _AbstractAuthRequestHandler { String providerId, OIDCUpdateAuthProviderRequest options, ) async { - if (!_OIDCConfig.isProviderId(providerId)) { + if (!OIDCAuthProviderConfig.isProviderId(providerId)) { throw FirebaseAuthAdminException(AuthClientErrorCode.invalidProviderId); } - final request = _OIDCConfig.buildServerRequest( + final request = OIDCAuthProviderConfig._buildServerRequest( options, ignoreMissingFields: true, ); @@ -222,7 +228,7 @@ abstract class _AbstractAuthRequestHandler { final name = response.name; if (name == null || - _OIDCConfig.getProviderIdFromResourceName(name) == null) { + OIDCAuthProviderConfig.getProviderIdFromResourceName(name) == null) { throw FirebaseAuthAdminException( AuthClientErrorCode.internalError, 'INTERNAL ASSERT FAILED: Unable to update OIDC configuration', @@ -238,11 +244,11 @@ abstract class _AbstractAuthRequestHandler { String providerId, SAMLUpdateAuthProviderRequest options, ) async { - if (!_SAMLConfig.isProviderId(providerId)) { + if (!SAMLAuthProviderConfig.isProviderId(providerId)) { throw FirebaseAuthAdminException(AuthClientErrorCode.invalidProviderId); } - final request = _SAMLConfig.buildServerRequest( + final request = SAMLAuthProviderConfig._buildServerRequest( options, ignoreMissingFields: true, ); @@ -255,7 +261,7 @@ abstract class _AbstractAuthRequestHandler { final name = response.name; if (name == null || - _SAMLConfig.getProviderIdFromResourceName(name) == null) { + SAMLAuthProviderConfig.getProviderIdFromResourceName(name) == null) { throw FirebaseAuthAdminException( AuthClientErrorCode.internalError, 'INTERNAL ASSERT FAILED: Unable to update SAML provider configuration', @@ -267,7 +273,7 @@ abstract class _AbstractAuthRequestHandler { /// Looks up an OIDC provider configuration by provider ID. Future getOAuthIdpConfig(String providerId) { - if (!_OIDCConfig.isProviderId(providerId)) { + if (!OIDCAuthProviderConfig.isProviderId(providerId)) { throw FirebaseAuthAdminException(AuthClientErrorCode.invalidProviderId); } @@ -276,7 +282,7 @@ abstract class _AbstractAuthRequestHandler { Future getInboundSamlConfig(String providerId) { - if (!_SAMLConfig.isProviderId(providerId)) { + if (!SAMLAuthProviderConfig.isProviderId(providerId)) { throw FirebaseAuthAdminException(AuthClientErrorCode.invalidProviderId); } @@ -285,7 +291,7 @@ abstract class _AbstractAuthRequestHandler { /// Deletes an OIDC configuration identified by a providerId. Future deleteOAuthIdpConfig(String providerId) { - if (!_OIDCConfig.isProviderId(providerId)) { + if (!OIDCAuthProviderConfig.isProviderId(providerId)) { throw FirebaseAuthAdminException(AuthClientErrorCode.invalidProviderId); } @@ -294,7 +300,7 @@ abstract class _AbstractAuthRequestHandler { /// Deletes a SAML configuration identified by a providerId. Future deleteInboundSamlConfig(String providerId) { - if (!_SAMLConfig.isProviderId(providerId)) { + if (!SAMLAuthProviderConfig.isProviderId(providerId)) { throw FirebaseAuthAdminException(AuthClientErrorCode.invalidProviderId); } @@ -305,8 +311,8 @@ abstract class _AbstractAuthRequestHandler { /// session management (set as a server side session cookie with custom cookie policy). /// The session cookie JWT will have the same payload claims as the provided ID token. Future createSessionCookie(String idToken, {required int expiresIn}) { - // Convert to seconds. - final validDuration = expiresIn / 1000; + // Convert to seconds (use integer division to avoid decimal). + final validDuration = expiresIn ~/ 1000; final request = auth1.GoogleCloudIdentitytoolkitV1CreateSessionCookieRequest( idToken: idToken, @@ -527,6 +533,10 @@ abstract class _AbstractAuthRequestHandler { Future getAccountInfoByUid( String uid, ) async { + if (!isUid(uid)) { + throw FirebaseAuthAdminException(AuthClientErrorCode.invalidUid); + } + final response = await _accountsLookup( auth1.GoogleCloudIdentitytoolkitV1GetAccountInfoRequest(localId: [uid]), ); @@ -566,9 +576,12 @@ abstract class _AbstractAuthRequestHandler { required String providerId, required String rawId, }) async { - if (providerId.isEmpty || rawId.isEmpty) { + if (providerId.isEmpty) { throw FirebaseAuthAdminException(AuthClientErrorCode.invalidProviderId); } + if (rawId.isEmpty) { + throw FirebaseAuthAdminException(AuthClientErrorCode.invalidUid); + } final response = await _accountsLookup( auth1.GoogleCloudIdentitytoolkitV1GetAccountInfoRequest( @@ -603,17 +616,39 @@ abstract class _AbstractAuthRequestHandler { for (final id in identifiers) { switch (id) { case UidIdentifier(): - final localIds = request.localId ?? []; - localIds.add(id.uid); + if (request.localId != null) { + request.localId!.add(id.uid); + } else { + request.localId = [id.uid]; + } case EmailIdentifier(): - final emails = request.email ?? []; - emails.add(id.email); + if (request.email != null) { + request.email!.add(id.email); + } else { + request.email = [id.email]; + } case PhoneIdentifier(): - final phoneNumbers = request.phoneNumber ?? []; - phoneNumbers.add(id.phoneNumber); + if (request.phoneNumber != null) { + request.phoneNumber!.add(id.phoneNumber); + } else { + request.phoneNumber = [id.phoneNumber]; + } case ProviderIdentifier(): - final providerIds = request.federatedUserId ?? []; - providerIds.add(id.providerId); + if (request.federatedUserId != null) { + request.federatedUserId!.add( + auth1.GoogleCloudIdentitytoolkitV1FederatedUserIdentifier( + providerId: id.providerId, + rawId: id.providerUid, + ), + ); + } else { + request.federatedUserId = [ + auth1.GoogleCloudIdentitytoolkitV1FederatedUserIdentifier( + providerId: id.providerId, + rawId: id.providerUid, + ), + ]; + } } } @@ -745,7 +780,7 @@ class AuthRequestHandler extends _AbstractAuthRequestHandler { } /// Looks up a tenant by tenant ID. - Future> _getTenant(String tenantId) async { + Future> getTenant(String tenantId) async { if (tenantId.isEmpty) { throw FirebaseAuthAdminException( AuthClientErrorCode.invalidTenantId, @@ -759,10 +794,17 @@ class AuthRequestHandler extends _AbstractAuthRequestHandler { /// Lists tenants (single batch only) with a size of maxResults and starting from /// the offset as specified by pageToken. - Future> _listTenants({ + Future> listTenants({ int maxResults = 1000, String? pageToken, }) async { + if (maxResults > 1000) { + throw FirebaseAuthAdminException( + AuthClientErrorCode.invalidArgument, + 'maxResults must not exceed 1000.', + ); + } + final response = await _httpClient.listTenants( maxResults: maxResults, pageToken: pageToken, @@ -783,7 +825,7 @@ class AuthRequestHandler extends _AbstractAuthRequestHandler { } /// Deletes a tenant identified by a tenantId. - Future _deleteTenant(String tenantId) async { + Future deleteTenant(String tenantId) async { if (tenantId.isEmpty) { throw FirebaseAuthAdminException( AuthClientErrorCode.invalidTenantId, @@ -795,7 +837,7 @@ class AuthRequestHandler extends _AbstractAuthRequestHandler { } /// Creates a new tenant with the properties provided. - Future> _createTenant( + Future> createTenant( CreateTenantRequest tenantOptions, ) async { final requestMap = Tenant._buildServerRequest(tenantOptions, true); @@ -807,7 +849,7 @@ class AuthRequestHandler extends _AbstractAuthRequestHandler { } /// Updates an existing tenant with the properties provided. - Future> _updateTenant( + Future> updateTenant( String tenantId, UpdateTenantRequest tenantOptions, ) async { @@ -861,12 +903,37 @@ class AuthRequestHandler extends _AbstractAuthRequestHandler { Map _mfaConfigToJson( auth2.GoogleCloudIdentitytoolkitAdminV2MultiFactorAuthConfig config, ) { + // Convert providerConfigs from Google API objects to JSON maps + List>? providerConfigsJson; + if (config.providerConfigs != null) { + providerConfigsJson = >[]; + for (final providerConfig in config.providerConfigs!) { + final configMap = {}; + + // Extract state + if (providerConfig.state != null) { + configMap['state'] = providerConfig.state; + } + + // Extract totpProviderConfig + if (providerConfig.totpProviderConfig != null) { + final totpConfig = {}; + if (providerConfig.totpProviderConfig!.adjacentIntervals != null) { + totpConfig['adjacentIntervals'] = + providerConfig.totpProviderConfig!.adjacentIntervals; + } + configMap['totpProviderConfig'] = totpConfig; + } + + providerConfigsJson.add(configMap); + } + } + return { if (config.state != null) 'state': config.state, if (config.enabledProviders != null) 'enabledProviders': config.enabledProviders, - if (config.providerConfigs != null) - 'providerConfigs': config.providerConfigs, + if (providerConfigsJson != null) 'providerConfigs': providerConfigsJson, }; } @@ -888,14 +955,83 @@ class AuthRequestHandler extends _AbstractAuthRequestHandler { Map _recaptchaConfigToJson( auth2.GoogleCloudIdentitytoolkitAdminV2RecaptchaConfig config, ) { - return { + final result = { if (config.emailPasswordEnforcementState != null) 'emailPasswordEnforcementState': config.emailPasswordEnforcementState, - if (config.phoneEnforcementState != null) - 'phoneEnforcementState': config.phoneEnforcementState, - if (config.useAccountDefender != null) - 'useAccountDefender': config.useAccountDefender, }; + + // phoneEnforcementState may not be in the Google API types yet, check if it exists + try { + final phoneState = (config as dynamic).phoneEnforcementState; + if (phoneState != null) { + result['phoneEnforcementState'] = phoneState; + } + } catch (_) { + // Field doesn't exist in API types yet + } + + if (config.useAccountDefender != null) { + result['useAccountDefender'] = config.useAccountDefender; + } + + // Add managedRules if present + if (config.managedRules != null) { + result['managedRules'] = config.managedRules!.map((rule) { + return { + 'endScore': rule.endScore, + if (rule.action != null) 'action': rule.action, + }; + }).toList(); + } + + // Add recaptchaKeys if present + if (config.recaptchaKeys != null) { + result['recaptchaKeys'] = config.recaptchaKeys!.map((key) { + return {'key': key.key, if (key.type != null) 'type': key.type}; + }).toList(); + } + + // useSmsBotScore may not be in the Google API types yet, check if it exists + try { + final useSmsBotScore = (config as dynamic).useSmsBotScore; + if (useSmsBotScore != null) { + result['useSmsBotScore'] = useSmsBotScore; + } + } catch (_) { + // Field doesn't exist in API types yet + } + + // useSmsTollFraudProtection may not be in the Google API types yet, check if it exists + try { + final useSmsTollFraudProtection = + (config as dynamic).useSmsTollFraudProtection; + if (useSmsTollFraudProtection != null) { + result['useSmsTollFraudProtection'] = useSmsTollFraudProtection; + } + } catch (_) { + // Field doesn't exist in API types yet + } + + // tollFraudManagedRules may not be in the Google API types yet, check if it exists + try { + final tollFraudManagedRules = (config as dynamic).tollFraudManagedRules; + if (tollFraudManagedRules != null) { + result['tollFraudManagedRules'] = + (tollFraudManagedRules as List).map((rule) { + final ruleMap = rule as Map; + return { + 'startScore': ruleMap['startScore'] is int + ? (ruleMap['startScore'] as int).toDouble() + : ruleMap['startScore'] as double, + if (ruleMap['action'] != null) 'action': ruleMap['action'], + }; + }).toList(); + } + } catch (_) { + // Field doesn't exist in API types yet + } + + return result; } Map _passwordPolicyConfigToJson( diff --git a/packages/dart_firebase_admin/lib/src/auth/base_auth.dart b/packages/dart_firebase_admin/lib/src/auth/base_auth.dart index f237ffe9..eb5de72a 100644 --- a/packages/dart_firebase_admin/lib/src/auth/base_auth.dart +++ b/packages/dart_firebase_admin/lib/src/auth/base_auth.dart @@ -19,16 +19,19 @@ abstract class _BaseAuth { required this.app, required _AbstractAuthRequestHandler authRequestHandler, _FirebaseTokenGenerator? tokenGenerator, + FirebaseTokenVerifier? idTokenVerifier, + FirebaseTokenVerifier? sessionCookieVerifier, }) : _authRequestHandler = authRequestHandler, _tokenGenerator = tokenGenerator ?? _createFirebaseTokenGenerator(app), - _sessionCookieVerifier = _createSessionCookieVerifier(app); + _sessionCookieVerifier = + sessionCookieVerifier ?? _createSessionCookieVerifier(app), + _idTokenVerifier = idTokenVerifier ?? _createIdTokenVerifier(app); final FirebaseApp app; final _AbstractAuthRequestHandler _authRequestHandler; final FirebaseTokenVerifier _sessionCookieVerifier; final _FirebaseTokenGenerator _tokenGenerator; - - late final _idTokenVerifier = _createIdTokenVerifier(app); + final FirebaseTokenVerifier _idTokenVerifier; /// Generates the out of band email action link to reset a user's password. /// The link is generated for the user with the specified email address. The @@ -178,7 +181,9 @@ abstract class _BaseAuth { return ListProviderConfigResults( providerConfigs: [ // Convert each provider config response to a OIDCConfig. - ...?response.oauthIdpConfigs?.map(_OIDCConfig.fromResponse), + ...?response.oauthIdpConfigs?.map( + OIDCAuthProviderConfig.fromResponse, + ), ], pageToken: response.nextPageToken, ); @@ -190,7 +195,9 @@ abstract class _BaseAuth { return ListProviderConfigResults( providerConfigs: [ // Convert each provider config response to a SAMLConfig. - ...?response.inboundSamlConfigs?.map(_SAMLConfig.fromResponse), + ...?response.inboundSamlConfigs?.map( + SAMLAuthProviderConfig.fromResponse, + ), ], pageToken: response.nextPageToken, ); @@ -211,16 +218,16 @@ abstract class _BaseAuth { Future createProviderConfig( AuthProviderConfig config, ) async { - if (_OIDCConfig.isProviderId(config.providerId)) { + if (OIDCAuthProviderConfig.isProviderId(config.providerId)) { final response = await _authRequestHandler.createOAuthIdpConfig( - config as _OIDCConfig, + config as OIDCAuthProviderConfig, ); - return _OIDCConfig.fromResponse(response); - } else if (_SAMLConfig.isProviderId(config.providerId)) { + return OIDCAuthProviderConfig.fromResponse(response); + } else if (SAMLAuthProviderConfig.isProviderId(config.providerId)) { final response = await _authRequestHandler.createInboundSamlConfig( - config as _SAMLConfig, + config as SAMLAuthProviderConfig, ); - return _SAMLConfig.fromResponse(response); + return SAMLAuthProviderConfig.fromResponse(response); } throw FirebaseAuthAdminException(AuthClientErrorCode.invalidProviderId); @@ -238,18 +245,18 @@ abstract class _BaseAuth { String providerId, UpdateAuthProviderRequest updatedConfig, ) async { - if (_OIDCConfig.isProviderId(providerId)) { + if (OIDCAuthProviderConfig.isProviderId(providerId)) { final response = await _authRequestHandler.updateOAuthIdpConfig( providerId, updatedConfig as OIDCUpdateAuthProviderRequest, ); - return _OIDCConfig.fromResponse(response); - } else if (_SAMLConfig.isProviderId(providerId)) { + return OIDCAuthProviderConfig.fromResponse(response); + } else if (SAMLAuthProviderConfig.isProviderId(providerId)) { final response = await _authRequestHandler.updateInboundSamlConfig( providerId, updatedConfig as SAMLUpdateAuthProviderRequest, ); - return _SAMLConfig.fromResponse(response); + return SAMLAuthProviderConfig.fromResponse(response); } throw FirebaseAuthAdminException(AuthClientErrorCode.invalidProviderId); @@ -267,14 +274,14 @@ abstract class _BaseAuth { /// - [providerId] - The provider ID corresponding to the provider /// config to return. Future getProviderConfig(String providerId) async { - if (_OIDCConfig.isProviderId(providerId)) { + if (OIDCAuthProviderConfig.isProviderId(providerId)) { final response = await _authRequestHandler.getOAuthIdpConfig(providerId); - return _OIDCConfig.fromResponse(response); - } else if (_SAMLConfig.isProviderId(providerId)) { + return OIDCAuthProviderConfig.fromResponse(response); + } else if (SAMLAuthProviderConfig.isProviderId(providerId)) { final response = await _authRequestHandler.getInboundSamlConfig( providerId, ); - return _SAMLConfig.fromResponse(response); + return SAMLAuthProviderConfig.fromResponse(response); } else { throw FirebaseAuthAdminException(AuthClientErrorCode.invalidProviderId); } @@ -288,9 +295,9 @@ abstract class _BaseAuth { /// (GCIP). To learn more about GCIP, including pricing and features, /// see the https://cloud.google.com/identity-platform. Future deleteProviderConfig(String providerId) { - if (_OIDCConfig.isProviderId(providerId)) { + if (OIDCAuthProviderConfig.isProviderId(providerId)) { return _authRequestHandler.deleteOAuthIdpConfig(providerId); - } else if (_SAMLConfig.isProviderId(providerId)) { + } else if (SAMLAuthProviderConfig.isProviderId(providerId)) { return _authRequestHandler.deleteInboundSamlConfig(providerId); } throw FirebaseAuthAdminException(AuthClientErrorCode.invalidProviderId); @@ -398,12 +405,12 @@ abstract class _BaseAuth { /// for code samples and detailed documentation. /// Future createSessionCookie( - String idToken, { - required int expiresIn, - }) async { + String idToken, + SessionCookieOptions sessionCookieOptions, + ) async { return _authRequestHandler.createSessionCookie( idToken, - expiresIn: expiresIn, + expiresIn: sessionCookieOptions.expiresIn, ); } @@ -854,3 +861,18 @@ class UserImportResult { /// length of this array is equal to [failureCount]. final List errors; } + +/// Interface representing the session cookie options needed for the +/// [_BaseAuth.createSessionCookie] method. +class SessionCookieOptions { + /// Creates a new [SessionCookieOptions] with the specified expiration time. + /// + /// The [expiresIn] is the session cookie custom expiration in milliseconds. + /// The minimum allowed is 5 minutes (300000 ms) and the maximum allowed is 2 weeks (1209600000 ms). + const SessionCookieOptions({required this.expiresIn}); + + /// The session cookie custom expiration in milliseconds. + /// + /// The minimum allowed is 5 minutes (300000 ms) and the maximum allowed is 2 weeks (1209600000 ms). + final int expiresIn; +} diff --git a/packages/dart_firebase_admin/lib/src/auth/tenant_manager.dart b/packages/dart_firebase_admin/lib/src/auth/tenant_manager.dart index 31ab1747..f35694b0 100644 --- a/packages/dart_firebase_admin/lib/src/auth/tenant_manager.dart +++ b/packages/dart_firebase_admin/lib/src/auth/tenant_manager.dart @@ -54,6 +54,24 @@ class TenantAwareAuth extends _BaseAuth { tokenGenerator: _createFirebaseTokenGenerator(app, tenantId: tenantId), ); + /// Internal constructor for testing. + /// + /// [app] - The app that created this tenant. + /// [tenantId] - The corresponding tenant ID. + /// [idTokenVerifier] - Optional ID token verifier for testing. + /// [sessionCookieVerifier] - Optional session cookie verifier for testing. + @internal + TenantAwareAuth.internal( + FirebaseApp app, + this.tenantId, { + super.idTokenVerifier, + super.sessionCookieVerifier, + }) : super( + app: app, + authRequestHandler: _TenantAwareAuthRequestHandler(app, tenantId), + tokenGenerator: _createFirebaseTokenGenerator(app, tenantId: tenantId), + ); + /// The tenant identifier corresponding to this `TenantAwareAuth` instance. /// All calls to the user management APIs, OIDC/SAML provider management APIs, email link /// generation APIs, etc will only be applied within the scope of this tenant. @@ -95,19 +113,24 @@ class TenantAwareAuth extends _BaseAuth { /// The session cookie JWT will have the same payload claims as the provided ID token. /// /// [idToken] - The Firebase ID token to exchange for a session cookie. - /// [expiresIn] - The session cookie custom expiration in milliseconds. The minimum allowed is - /// 5 minutes and the maxium allowed is 2 weeks. + /// [sessionCookieOptions] - The session cookie options which includes custom expiration + /// in milliseconds. The minimum allowed is 5 minutes and the maxium allowed is 2 weeks. /// /// Returns a [Future] that resolves with the created session cookie. @override Future createSessionCookie( - String idToken, { - required int expiresIn, - }) async { + String idToken, + SessionCookieOptions sessionCookieOptions, + ) async { + // Validate idToken is not empty before verification. + if (idToken.isEmpty) { + throw FirebaseAuthAdminException(AuthClientErrorCode.invalidIdToken); + } + // Verify the ID token and check tenant ID before creating session cookie. await verifyIdToken(idToken); - return super.createSessionCookie(idToken, expiresIn: expiresIn); + return super.createSessionCookie(idToken, sessionCookieOptions); } /// Verifies a Firebase session cookie. Returns a [Future] with the session cookie's decoded claims @@ -157,6 +180,15 @@ class TenantManager { : _authRequestHandler = AuthRequestHandler(_app), _tenantsMap = {}; + /// Internal constructor for testing. + /// + /// [FirebaseApp] - The app for this TenantManager instance. + /// [authRequestHandler] - Optional request handler for testing. + @internal + TenantManager.internal(this._app, {AuthRequestHandler? authRequestHandler}) + : _authRequestHandler = authRequestHandler ?? AuthRequestHandler(_app), + _tenantsMap = {}; + final FirebaseApp _app; final AuthRequestHandler _authRequestHandler; final Map _tenantsMap; @@ -186,7 +218,7 @@ class TenantManager { /// /// Returns a [Future] fulfilled with the tenant configuration for the provided [tenantId]. Future getTenant(String tenantId) async { - final response = await _authRequestHandler._getTenant(tenantId); + final response = await _authRequestHandler.getTenant(tenantId); return Tenant._fromResponse(response); } @@ -204,7 +236,7 @@ class TenantManager { int maxResults = 1000, String? pageToken, }) async { - final response = await _authRequestHandler._listTenants( + final response = await _authRequestHandler.listTenants( maxResults: maxResults, pageToken: pageToken, ); @@ -231,7 +263,7 @@ class TenantManager { /// /// Returns a [Future] that completes once the tenant has been deleted. Future deleteTenant(String tenantId) async { - await _authRequestHandler._deleteTenant(tenantId); + await _authRequestHandler.deleteTenant(tenantId); } /// Creates a new tenant. @@ -243,7 +275,7 @@ class TenantManager { /// Returns a [Future] fulfilled with the tenant configuration corresponding to the newly /// created tenant. Future createTenant(CreateTenantRequest tenantOptions) async { - final response = await _authRequestHandler._createTenant(tenantOptions); + final response = await _authRequestHandler.createTenant(tenantOptions); return Tenant._fromResponse(response); } @@ -257,7 +289,7 @@ class TenantManager { String tenantId, UpdateTenantRequest tenantOptions, ) async { - final response = await _authRequestHandler._updateTenant( + final response = await _authRequestHandler.updateTenant( tenantId, tenantOptions, ); diff --git a/packages/dart_firebase_admin/lib/src/auth/token_verifier.dart b/packages/dart_firebase_admin/lib/src/auth/token_verifier.dart index 1fd4e949..109c81db 100644 --- a/packages/dart_firebase_admin/lib/src/auth/token_verifier.dart +++ b/packages/dart_firebase_admin/lib/src/auth/token_verifier.dart @@ -113,7 +113,24 @@ class FirebaseTokenVerifier { } Future _safeDecode(String jtwToken) async { - return _authGuard(() => dart_jsonwebtoken.JWT.decode(jtwToken)); + try { + return dart_jsonwebtoken.JWT.decode(jtwToken); + } catch (error, stackTrace) { + // JWT.decode() throws JWTUndefinedException for invalid tokens + // Convert to FirebaseAuthAdminException with auth/argument-error + final verifyJwtTokenDocsMessage = + ' See ${tokenInfo.url} ' + 'for details on how to retrieve $_shortNameArticle ${tokenInfo.shortName}.'; + final errorMessage = + '${tokenInfo.jwtName} has invalid format.$verifyJwtTokenDocsMessage'; + Error.throwWithStackTrace( + FirebaseAuthAdminException( + AuthClientErrorCode.invalidArgument, + errorMessage, + ), + stackTrace, + ); + } } Future _verifySignature( diff --git a/packages/dart_firebase_admin/lib/src/auth/user.dart b/packages/dart_firebase_admin/lib/src/auth/user.dart index ba690410..f8655619 100644 --- a/packages/dart_firebase_admin/lib/src/auth/user.dart +++ b/packages/dart_firebase_admin/lib/src/auth/user.dart @@ -296,17 +296,19 @@ abstract class MultiFactorInfo { /// If no MultiFactorInfo is associated with the response, null is returned. /// /// @param response - The server side response. - /// @internal + @internal static MultiFactorInfo? initMultiFactorInfo( auth1.GoogleCloudIdentitytoolkitV1MfaEnrollment response, ) { // PhoneMultiFactorInfo, TotpMultiFactorInfo currently available. try { final phoneInfo = response.phoneInfo; - // TODO Support TotpMultiFactorInfo + final totpInfo = response.totpInfo; if (phoneInfo != null) { return PhoneMultiFactorInfo.fromResponse(response); + } else if (totpInfo != null) { + return TotpMultiFactorInfo.fromResponse(response); } // Ignore the other SDK unsupported MFA factors to prevent blocking developers using the current SDK. } catch (e) { @@ -363,6 +365,34 @@ class PhoneMultiFactorInfo extends MultiFactorInfo { } } +/// Represents TOTP (Time-based One-time Password) information for second factor authentication. +/// This class is used with authenticator apps like Google Authenticator, Authy, etc. +/// It serves as a marker class with no additional properties beyond what's inherited from MultiFactorInfo. +class TotpInfo { + /// Creates a new [TotpInfo] instance. + TotpInfo(); +} + +/// Interface representing a TOTP specific user-enrolled second factor. +class TotpMultiFactorInfo extends MultiFactorInfo { + /// Initializes the TotpMultiFactorInfo object using the server side response. + @internal + TotpMultiFactorInfo.fromResponse(super.response) + : totpInfo = TotpInfo(), + super.fromResponse(); + + /// The `TotpInfo` struct associated with a second factor. + final TotpInfo totpInfo; + + @override + MultiFactorId get factorId => MultiFactorId.totp; + + @override + Map toJson() { + return {...super.toJson(), 'totpInfo': {}}; + } +} + /// Metadata information about when a user was created and last signed in. class UserMetadata { /// Metadata information about when a user was created and last signed in. diff --git a/packages/dart_firebase_admin/lib/src/messaging/messaging.dart b/packages/dart_firebase_admin/lib/src/messaging/messaging.dart index 2a2e9f8a..5a32355c 100644 --- a/packages/dart_firebase_admin/lib/src/messaging/messaging.dart +++ b/packages/dart_firebase_admin/lib/src/messaging/messaging.dart @@ -23,20 +23,31 @@ const _fmcMaxBatchSize = 500; /// An interface for interacting with the Firebase Cloud Messaging service. class Messaging implements FirebaseService { /// Creates or returns the cached Messaging instance for the given app. - factory Messaging( + factory Messaging(FirebaseApp app) { + return app.getOrInitService( + FirebaseServiceType.messaging.name, + Messaging._, + ); + } + + /// An interface for interacting with the Firebase Cloud Messaging service. + Messaging._(this.app) + : _requestHandler = FirebaseMessagingRequestHandler(app); + + @internal + factory Messaging.internal( FirebaseApp app, { - @internal FirebaseMessagingRequestHandler? requestHandler, + FirebaseMessagingRequestHandler? requestHandler, }) { return app.getOrInitService( FirebaseServiceType.messaging.name, - (app) => Messaging._(app, requestHandler: requestHandler), + (app) => Messaging._internal(app, requestHandler: requestHandler), ); } - /// An interface for interacting with the Firebase Cloud Messaging service. - Messaging._( + Messaging._internal( this.app, { - @internal FirebaseMessagingRequestHandler? requestHandler, + FirebaseMessagingRequestHandler? requestHandler, }) : _requestHandler = requestHandler ?? FirebaseMessagingRequestHandler(app); /// The app associated with this Messaging instance. diff --git a/packages/dart_firebase_admin/test/app/firebase_app_test.dart b/packages/dart_firebase_admin/test/app/firebase_app_test.dart index 66157f3c..82f81463 100644 --- a/packages/dart_firebase_admin/test/app/firebase_app_test.dart +++ b/packages/dart_firebase_admin/test/app/firebase_app_test.dart @@ -12,6 +12,8 @@ import 'package:test/test.dart'; import '../mock.dart'; import '../mock_service_account.dart'; +// TODO(demolaf): check if we have sufficient tests for firebase app initialization +// logic void main() { group('FirebaseApp', () { group('initializeApp', () { @@ -501,7 +503,7 @@ void main() { ); // Initialize auth service with our request handler - Auth(app, requestHandler: requestHandler); + Auth.internal(app, requestHandler: requestHandler); // Verify emulator is enabled expect(Environment.isAuthEmulatorEnabled(), isTrue); diff --git a/packages/dart_firebase_admin/test/app_check/app_check_test.dart b/packages/dart_firebase_admin/test/app_check/app_check_test.dart index d2913e75..939d6a32 100644 --- a/packages/dart_firebase_admin/test/app_check/app_check_test.dart +++ b/packages/dart_firebase_admin/test/app_check/app_check_test.dart @@ -1,5 +1,7 @@ +import 'dart:async'; +import 'dart:io'; import 'package:dart_firebase_admin/app_check.dart'; -import 'package:dart_firebase_admin/dart_firebase_admin.dart'; +import 'package:dart_firebase_admin/src/app.dart'; import 'package:dart_firebase_admin/src/app_check/app_check.dart'; import 'package:dart_firebase_admin/src/app_check/token_generator.dart'; import 'package:dart_firebase_admin/src/app_check/token_verifier.dart'; @@ -324,71 +326,112 @@ void main() { }); group('e2e', () { - late AppCheck realAppCheck; - - setUp(() { - final sdk = createApp(); - realAppCheck = AppCheck(sdk); - }); - test( skip: hasGoogleEnv ? false : 'Requires GOOGLE_APPLICATION_CREDENTIALS', 'should create and verify token', - () async { - final token = await realAppCheck.createToken( - '1:559949546715:android:13025aec6cc3243d0ab8fe', - ); - - expect(token.token, isNotEmpty); - expect(token.ttlMillis, greaterThan(0)); - - final result = await realAppCheck.verifyToken(token.token); - - expect(result.appId, isNotEmpty); - expect(result.token, isNotNull); - expect(result.alreadyConsumed, isNull); + () { + // Remove emulator env var from the zone environment + final prodEnv = Map.from(Platform.environment); + // App Check doesn't have emulator yet, but keep pattern consistent + // prodEnv.remove(Environment.appCheckEmulatorHost); + + return runZoned(() async { + final appName = + 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final appCheck = AppCheck(app); + + try { + final token = await appCheck.createToken( + '1:559949546715:android:13025aec6cc3243d0ab8fe', + ); + + expect(token.token, isNotEmpty); + expect(token.ttlMillis, greaterThan(0)); + + final result = await appCheck.verifyToken(token.token); + + expect(result.appId, isNotEmpty); + expect(result.token, isNotNull); + expect(result.alreadyConsumed, isNull); + } finally { + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv}); }, ); test( skip: hasGoogleEnv ? false : 'Requires GOOGLE_APPLICATION_CREDENTIALS', 'should create token with custom ttl', - () async { - final token = await realAppCheck.createToken( - '1:559949546715:android:13025aec6cc3243d0ab8fe', - AppCheckTokenOptions(ttlMillis: const Duration(hours: 2)), - ); - - expect(token.token, isNotEmpty); - // TTL might not be exactly what we requested, but should be reasonable - expect(token.ttlMillis, greaterThan(0)); + () { + // Remove emulator env var from the zone environment + final prodEnv = Map.from(Platform.environment); + // App Check doesn't have emulator yet, but keep pattern consistent + // prodEnv.remove(Environment.appCheckEmulatorHost); + + return runZoned(() async { + final appName = + 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final appCheck = AppCheck(app); + + try { + final token = await appCheck.createToken( + '1:559949546715:android:13025aec6cc3243d0ab8fe', + AppCheckTokenOptions(ttlMillis: const Duration(hours: 2)), + ); + + expect(token.token, isNotEmpty); + // TTL might not be exactly what we requested, but should be reasonable + expect(token.ttlMillis, greaterThan(0)); + } finally { + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv}); }, ); test( skip: hasGoogleEnv ? false : 'Requires GOOGLE_APPLICATION_CREDENTIALS', 'should verify token with consume option', - () async { - final token = await realAppCheck.createToken( - '1:559949546715:android:13025aec6cc3243d0ab8fe', - ); - - final result = await realAppCheck.verifyToken( - token.token, - VerifyAppCheckTokenOptions()..consume = true, - ); - - expect(result.appId, isNotEmpty); - expect(result.token, isNotNull); - expect(result.alreadyConsumed, equals(false)); - - // Verify same token again - should be marked as consumed - final result2 = await realAppCheck.verifyToken( - token.token, - VerifyAppCheckTokenOptions()..consume = true, - ); - - expect(result2.alreadyConsumed, equals(true)); + () { + // Remove emulator env var from the zone environment + final prodEnv = Map.from(Platform.environment); + // App Check doesn't have emulator yet, but keep pattern consistent + // prodEnv.remove(Environment.appCheckEmulatorHost); + + return runZoned(() async { + final appName = + 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final appCheck = AppCheck(app); + + try { + final token = await appCheck.createToken( + '1:559949546715:android:13025aec6cc3243d0ab8fe', + ); + + final result = await appCheck.verifyToken( + token.token, + VerifyAppCheckTokenOptions()..consume = true, + ); + + expect(result.appId, isNotEmpty); + expect(result.token, isNotNull); + expect(result.alreadyConsumed, equals(false)); + + // Verify same token again - should be marked as consumed + final result2 = await appCheck.verifyToken( + token.token, + VerifyAppCheckTokenOptions()..consume = true, + ); + + expect(result2.alreadyConsumed, equals(true)); + } finally { + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv}); }, ); }); diff --git a/packages/dart_firebase_admin/test/auth/auth_config_tenant_test.dart b/packages/dart_firebase_admin/test/auth/auth_config_tenant_test.dart index 96f47d4d..35297b5a 100644 --- a/packages/dart_firebase_admin/test/auth/auth_config_tenant_test.dart +++ b/packages/dart_firebase_admin/test/auth/auth_config_tenant_test.dart @@ -155,13 +155,168 @@ void main() { }); }); + group('RecaptchaAction', () { + test('has correct value', () { + expect(RecaptchaAction.block.value, equals('BLOCK')); + }); + + test('fromString returns correct enum', () { + expect( + RecaptchaAction.fromString('BLOCK'), + equals(RecaptchaAction.block), + ); + expect( + RecaptchaAction.fromString('INVALID'), + equals(RecaptchaAction.block), + ); // Default fallback + }); + }); + + group('RecaptchaKeyClientType', () { + test('has correct values', () { + expect(RecaptchaKeyClientType.web.value, equals('WEB')); + expect(RecaptchaKeyClientType.ios.value, equals('IOS')); + expect(RecaptchaKeyClientType.android.value, equals('ANDROID')); + }); + + test('fromString returns correct enum', () { + expect( + RecaptchaKeyClientType.fromString('WEB'), + equals(RecaptchaKeyClientType.web), + ); + expect( + RecaptchaKeyClientType.fromString('IOS'), + equals(RecaptchaKeyClientType.ios), + ); + expect( + RecaptchaKeyClientType.fromString('ANDROID'), + equals(RecaptchaKeyClientType.android), + ); + expect( + RecaptchaKeyClientType.fromString('INVALID'), + equals(RecaptchaKeyClientType.web), + ); // Default fallback + }); + }); + + group('RecaptchaManagedRule', () { + test('creates rule with required fields', () { + const rule = RecaptchaManagedRule(endScore: 0.5); + + expect(rule.endScore, equals(0.5)); + expect(rule.action, isNull); + }); + + test('creates rule with action', () { + const rule = RecaptchaManagedRule( + endScore: 0.5, + action: RecaptchaAction.block, + ); + + expect(rule.endScore, equals(0.5)); + expect(rule.action, equals(RecaptchaAction.block)); + }); + + test('serializes to JSON', () { + const rule = RecaptchaManagedRule( + endScore: 0.5, + action: RecaptchaAction.block, + ); + + final json = rule.toJson(); + + expect(json['endScore'], equals(0.5)); + expect(json['action'], equals('BLOCK')); + }); + + test('serializes to JSON without action', () { + const rule = RecaptchaManagedRule(endScore: 0.5); + + final json = rule.toJson(); + + expect(json['endScore'], equals(0.5)); + expect(json.containsKey('action'), isFalse); + }); + }); + + group('RecaptchaTollFraudManagedRule', () { + test('creates rule with required fields', () { + const rule = RecaptchaTollFraudManagedRule(startScore: 0.3); + + expect(rule.startScore, equals(0.3)); + expect(rule.action, isNull); + }); + + test('creates rule with action', () { + const rule = RecaptchaTollFraudManagedRule( + startScore: 0.3, + action: RecaptchaAction.block, + ); + + expect(rule.startScore, equals(0.3)); + expect(rule.action, equals(RecaptchaAction.block)); + }); + + test('serializes to JSON', () { + const rule = RecaptchaTollFraudManagedRule( + startScore: 0.3, + action: RecaptchaAction.block, + ); + + final json = rule.toJson(); + + expect(json['startScore'], equals(0.3)); + expect(json['action'], equals('BLOCK')); + }); + }); + + group('RecaptchaKey', () { + test('creates key with required fields', () { + const key = RecaptchaKey(key: 'test-key'); + + expect(key.key, equals('test-key')); + expect(key.type, isNull); + }); + + test('creates key with type', () { + const key = RecaptchaKey( + key: 'test-key', + type: RecaptchaKeyClientType.web, + ); + + expect(key.key, equals('test-key')); + expect(key.type, equals(RecaptchaKeyClientType.web)); + }); + + test('serializes to JSON', () { + const key = RecaptchaKey( + key: 'test-key', + type: RecaptchaKeyClientType.ios, + ); + + final json = key.toJson(); + + expect(json['key'], equals('test-key')); + expect(json['type'], equals('IOS')); + }); + }); + group('RecaptchaConfig', () { test('creates config with all fields', () { final config = RecaptchaConfig( emailPasswordEnforcementState: RecaptchaProviderEnforcementState.enforce, phoneEnforcementState: RecaptchaProviderEnforcementState.audit, + managedRules: [const RecaptchaManagedRule(endScore: 0.5)], + recaptchaKeys: [ + const RecaptchaKey(key: 'test-key', type: RecaptchaKeyClientType.web), + ], useAccountDefender: true, + useSmsBotScore: true, + useSmsTollFraudProtection: false, + smsTollFraudManagedRules: [ + const RecaptchaTollFraudManagedRule(startScore: 0.3), + ], ); expect( @@ -172,7 +327,15 @@ void main() { config.phoneEnforcementState, equals(RecaptchaProviderEnforcementState.audit), ); + expect(config.managedRules, isNotNull); + expect(config.managedRules!.length, equals(1)); + expect(config.recaptchaKeys, isNotNull); + expect(config.recaptchaKeys!.length, equals(1)); expect(config.useAccountDefender, isTrue); + expect(config.useSmsBotScore, isTrue); + expect(config.useSmsTollFraudProtection, isFalse); + expect(config.smsTollFraudManagedRules, isNotNull); + expect(config.smsTollFraudManagedRules!.length, equals(1)); }); test('creates config with no fields', () { @@ -180,7 +343,12 @@ void main() { expect(config.emailPasswordEnforcementState, isNull); expect(config.phoneEnforcementState, isNull); + expect(config.managedRules, isNull); + expect(config.recaptchaKeys, isNull); expect(config.useAccountDefender, isNull); + expect(config.useSmsBotScore, isNull); + expect(config.useSmsTollFraudProtection, isNull); + expect(config.smsTollFraudManagedRules, isNull); }); test('serializes to JSON', () { @@ -188,7 +356,24 @@ void main() { emailPasswordEnforcementState: RecaptchaProviderEnforcementState.enforce, phoneEnforcementState: RecaptchaProviderEnforcementState.audit, + managedRules: [ + const RecaptchaManagedRule( + endScore: 0.5, + action: RecaptchaAction.block, + ), + ], + recaptchaKeys: [ + const RecaptchaKey(key: 'test-key', type: RecaptchaKeyClientType.web), + ], useAccountDefender: true, + useSmsBotScore: true, + useSmsTollFraudProtection: false, + smsTollFraudManagedRules: [ + const RecaptchaTollFraudManagedRule( + startScore: 0.3, + action: RecaptchaAction.block, + ), + ], ); final json = config.toJson(); @@ -196,6 +381,24 @@ void main() { expect(json['emailPasswordEnforcementState'], equals('ENFORCE')); expect(json['phoneEnforcementState'], equals('AUDIT')); expect(json['useAccountDefender'], isTrue); + expect(json['useSmsBotScore'], isTrue); + expect(json['useSmsTollFraudProtection'], isFalse); + expect(json['managedRules'], isA>()); + final managedRulesList = json['managedRules'] as List; + final managedRule = managedRulesList[0] as Map; + expect(managedRule['endScore'], equals(0.5)); + expect(managedRule['action'], equals('BLOCK')); + expect(json['recaptchaKeys'], isA>()); + final recaptchaKeysList = json['recaptchaKeys'] as List; + final recaptchaKey = recaptchaKeysList[0] as Map; + expect(recaptchaKey['key'], equals('test-key')); + expect(recaptchaKey['type'], equals('WEB')); + expect(json['smsTollFraudManagedRules'], isA>()); + final smsTollFraudRulesList = + json['smsTollFraudManagedRules'] as List; + final smsTollFraudRule = smsTollFraudRulesList[0] as Map; + expect(smsTollFraudRule['startScore'], equals(0.3)); + expect(smsTollFraudRule['action'], equals('BLOCK')); }); }); @@ -348,4 +551,188 @@ void main() { expect(authFactorTypePhone, equals('phone')); }); }); + + group('TotpMultiFactorProviderConfig', () { + test('creates config without adjacentIntervals', () { + final config = TotpMultiFactorProviderConfig(); + + expect(config.adjacentIntervals, isNull); + }); + + test('creates config with valid adjacentIntervals', () { + final config = TotpMultiFactorProviderConfig(adjacentIntervals: 5); + + expect(config.adjacentIntervals, equals(5)); + }); + + test('creates config with minimum adjacentIntervals (0)', () { + final config = TotpMultiFactorProviderConfig(adjacentIntervals: 0); + + expect(config.adjacentIntervals, equals(0)); + }); + + test('creates config with maximum adjacentIntervals (10)', () { + final config = TotpMultiFactorProviderConfig(adjacentIntervals: 10); + + expect(config.adjacentIntervals, equals(10)); + }); + + test('throws when adjacentIntervals is negative', () { + expect( + () => TotpMultiFactorProviderConfig(adjacentIntervals: -1), + throwsA( + isA().having( + (e) => e.errorCode, + 'errorCode', + AuthClientErrorCode.invalidArgument, + ), + ), + ); + }); + + test('throws when adjacentIntervals exceeds maximum', () { + expect( + () => TotpMultiFactorProviderConfig(adjacentIntervals: 11), + throwsA( + isA().having( + (e) => e.errorCode, + 'errorCode', + AuthClientErrorCode.invalidArgument, + ), + ), + ); + }); + + test('serializes to JSON with adjacentIntervals', () { + final config = TotpMultiFactorProviderConfig(adjacentIntervals: 3); + + final json = config.toJson(); + + expect(json['adjacentIntervals'], equals(3)); + }); + + test('serializes to JSON without adjacentIntervals', () { + final config = TotpMultiFactorProviderConfig(); + + final json = config.toJson(); + + expect(json.containsKey('adjacentIntervals'), isFalse); + }); + }); + + group('MultiFactorProviderConfig', () { + test('creates config with required fields', () { + final config = MultiFactorProviderConfig( + state: MultiFactorConfigState.enabled, + totpProviderConfig: TotpMultiFactorProviderConfig(), + ); + + expect(config.state, equals(MultiFactorConfigState.enabled)); + expect(config.totpProviderConfig, isNotNull); + }); + + test('throws when totpProviderConfig is not provided', () { + expect( + () => MultiFactorProviderConfig(state: MultiFactorConfigState.enabled), + throwsA( + isA().having( + (e) => e.errorCode, + 'errorCode', + AuthClientErrorCode.invalidConfig, + ), + ), + ); + }); + + test('serializes to JSON correctly', () { + final config = MultiFactorProviderConfig( + state: MultiFactorConfigState.enabled, + totpProviderConfig: TotpMultiFactorProviderConfig(adjacentIntervals: 5), + ); + + final json = config.toJson(); + + expect(json['state'], equals('ENABLED')); + expect(json['totpProviderConfig'], isA>()); + expect( + (json['totpProviderConfig'] + as Map)['adjacentIntervals'], + equals(5), + ); + }); + + test('serializes to JSON with disabled state', () { + final config = MultiFactorProviderConfig( + state: MultiFactorConfigState.disabled, + totpProviderConfig: TotpMultiFactorProviderConfig(), + ); + + final json = config.toJson(); + + expect(json['state'], equals('DISABLED')); + expect(json['totpProviderConfig'], isA>()); + }); + }); + + group('MultiFactorConfig', () { + test('creates config with providerConfigs', () { + final config = MultiFactorConfig( + state: MultiFactorConfigState.enabled, + providerConfigs: [ + MultiFactorProviderConfig( + state: MultiFactorConfigState.enabled, + totpProviderConfig: TotpMultiFactorProviderConfig( + adjacentIntervals: 3, + ), + ), + ], + ); + + expect(config.providerConfigs, isNotNull); + expect(config.providerConfigs, hasLength(1)); + expect( + config.providerConfigs![0].totpProviderConfig?.adjacentIntervals, + equals(3), + ); + }); + + test('serializes to JSON with providerConfigs', () { + final config = MultiFactorConfig( + state: MultiFactorConfigState.enabled, + providerConfigs: [ + MultiFactorProviderConfig( + state: MultiFactorConfigState.enabled, + totpProviderConfig: TotpMultiFactorProviderConfig( + adjacentIntervals: 7, + ), + ), + ], + ); + + final json = config.toJson(); + + expect(json['providerConfigs'], isList); + expect(json['providerConfigs'], hasLength(1)); + final providerConfig = + (json['providerConfigs'] as List)[0] as Map; + expect(providerConfig['state'], equals('ENABLED')); + expect( + (providerConfig['totpProviderConfig'] + as Map)['adjacentIntervals'], + equals(7), + ); + }); + + test('serializes to JSON without providerConfigs', () { + final config = MultiFactorConfig( + state: MultiFactorConfigState.disabled, + factorIds: [authFactorTypePhone], + ); + + final json = config.toJson(); + + expect(json.containsKey('providerConfigs'), isFalse); + expect(json['factorIds'], isNotNull); + }); + }); } diff --git a/packages/dart_firebase_admin/test/auth/auth_integration_prod_test.dart b/packages/dart_firebase_admin/test/auth/auth_integration_prod_test.dart new file mode 100644 index 00000000..8935452d --- /dev/null +++ b/packages/dart_firebase_admin/test/auth/auth_integration_prod_test.dart @@ -0,0 +1,524 @@ +// Firebase Auth Integration Tests - Production Only +// +// These tests require production Firebase (GOOGLE_APPLICATION_CREDENTIALS) +// because they test features not available in the emulator: +// - Session cookies (require GCIP) +// - getUsers (not fully supported in emulator) +// - Provider configs (require GCIP) +// - Custom claims null behavior (emulator returns {} instead of null) +// +// **IMPORTANT:** These tests use runZoned with zoneValues to temporarily +// disable the emulator environment variable. This allows them to run in the +// coverage script (which has emulator vars set) by connecting to production +// only for these specific tests. +// +// Run standalone with: +// GOOGLE_APPLICATION_CREDENTIALS=service-account-key.json dart test test/auth/auth_integration_prod_test.dart +// +// Or as part of coverage (they auto-detect and disable emulator): +// FIREBASE_AUTH_EMULATOR_HOST=localhost:9099 GOOGLE_APPLICATION_CREDENTIALS=service-account-key.json dart test + +import 'dart:async'; +import 'dart:io'; + +import 'package:dart_firebase_admin/auth.dart'; +import 'package:dart_firebase_admin/src/app.dart'; +import 'package:googleapis/identitytoolkit/v1.dart'; +import 'package:test/test.dart'; +import 'package:uuid/uuid.dart'; + +import '../google_cloud_firestore/util/helpers.dart'; + +const _uid = Uuid(); + +void main() { + group('setCustomUserClaims (Production)', () { + test( + 'clears custom claims when null is passed', + () { + // Remove emulator env var from the zone environment + final prodEnv = Map.from(Platform.environment); + prodEnv.remove(Environment.firebaseAuthEmulatorHost); + + return runZoned(() async { + final appName = 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final testAuth = Auth(app); + + UserRecord? user; + try { + user = await testAuth.createUser(CreateRequest(uid: _uid.v4())); + await testAuth.setCustomUserClaims( + user.uid, + customUserClaims: {'role': 'admin'}, + ); + + await testAuth.setCustomUserClaims(user.uid); + + final updatedUser = await testAuth.getUser(user.uid); + // When custom claims are cleared, Firebase returns an empty map, not null + // This matches Node SDK behavior: expect(userRecord.customClaims).to.deep.equal({}) + expect(updatedUser.customClaims, isEmpty); + } finally { + if (user != null) { + await testAuth.deleteUser(user.uid); + } + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv}); + }, + skip: hasGoogleEnv + ? false + : 'Requires production to verify custom claims clearing', + ); + }); + + group('Session Cookies (Production)', () { + // Note: Session cookies require GCIP (Google Cloud Identity Platform) + // and are not available in the Auth Emulator. Most tests wrap the test body + // in runZoned to ensure the zone environment (without emulator) stays active. + test( + 'creates and verifies a valid session cookie', + () { + // Remove emulator env var from the zone environment + final prodEnv = Map.from(Platform.environment); + prodEnv.remove(Environment.firebaseAuthEmulatorHost); + + return runZoned(() async { + final appName = 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final testAuth = Auth(app); + + // Helper function to exchange custom token for ID token + Future getIdTokenFromCustomToken(String customToken) async { + final client = await testAuth.app.client; + final api = IdentityToolkitApi(client); + + final request = + GoogleCloudIdentitytoolkitV1SignInWithCustomTokenRequest( + token: customToken, + returnSecureToken: true, + ); + + final response = await api.accounts.signInWithCustomToken(request); + + if (response.idToken == null || response.idToken!.isEmpty) { + throw Exception( + 'Failed to exchange custom token for ID token: No idToken in response', + ); + } + + return response.idToken!; + } + + UserRecord? user; + try { + user = await testAuth.createUser(CreateRequest(uid: _uid.v4())); + + final customToken = await testAuth.createCustomToken(user.uid); + final idToken = await getIdTokenFromCustomToken(customToken); + + const expiresIn = 24 * 60 * 60 * 1000; // 24 hours + final sessionCookie = await testAuth.createSessionCookie( + idToken, + const SessionCookieOptions(expiresIn: expiresIn), + ); + + expect(sessionCookie, isNotEmpty); + + final decodedToken = await testAuth.verifySessionCookie( + sessionCookie, + ); + expect(decodedToken.uid, equals(user.uid)); + expect(decodedToken.iss, contains('session.firebase.google.com')); + } finally { + if (user != null) { + await testAuth.deleteUser(user.uid); + } + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv}); + }, + skip: hasGoogleEnv + ? false + : 'Session cookies require GCIP (not available in emulator)', + ); + + // Note: Session cookies require GCIP (Google Cloud Identity Platform) + // and are not available in the Auth Emulator. This test wraps the test body + // in runZoned to ensure the zone environment (without emulator) stays active. + test( + 'creates a revocable session cookie', + () { + // Remove emulator env var from the zone environment + final prodEnv = Map.from(Platform.environment); + prodEnv.remove(Environment.firebaseAuthEmulatorHost); + + return runZoned(() async { + final appName = 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final testAuth = Auth(app); + + // Helper function to exchange custom token for ID token + Future getIdTokenFromCustomToken(String customToken) async { + final client = await testAuth.app.client; + final api = IdentityToolkitApi(client); + + final request = + GoogleCloudIdentitytoolkitV1SignInWithCustomTokenRequest( + token: customToken, + returnSecureToken: true, + ); + + final response = await api.accounts.signInWithCustomToken(request); + + if (response.idToken == null || response.idToken!.isEmpty) { + throw Exception( + 'Failed to exchange custom token for ID token: No idToken in response', + ); + } + + return response.idToken!; + } + + try { + final user = await testAuth.createUser( + CreateRequest(uid: _uid.v4()), + ); + + final customToken = await testAuth.createCustomToken(user.uid); + final idToken = await getIdTokenFromCustomToken(customToken); + + const expiresIn = 24 * 60 * 60 * 1000; + final sessionCookie = await testAuth.createSessionCookie( + idToken, + const SessionCookieOptions(expiresIn: expiresIn), + ); + + final decodedToken = await testAuth.verifySessionCookie( + sessionCookie, + ); + expect(decodedToken.uid, equals(user.uid)); + + await Future.delayed(const Duration(seconds: 2)); + await testAuth.revokeRefreshTokens(user.uid); + + // Without checkRevoked, should not throw + await testAuth.verifySessionCookie(sessionCookie); + + // With checkRevoked: true, should throw + await expectLater( + () => testAuth.verifySessionCookie( + sessionCookie, + checkRevoked: true, + ), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/session-cookie-revoked', + ), + ), + ); + await testAuth.deleteUser(user.uid); + } finally { + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv}); + }, + skip: hasGoogleEnv + ? false + : 'Session cookies require GCIP (not available in emulator)', + ); + + // Note: Session cookies require GCIP (Google Cloud Identity Platform) + // and are not available in the Auth Emulator. This test wraps the test body + // in runZoned to ensure the zone environment (without emulator) stays active. + test( + 'fails when ID token is revoked', + () { + // Remove emulator env var from the zone environment + final prodEnv = Map.from(Platform.environment); + prodEnv.remove(Environment.firebaseAuthEmulatorHost); + + return runZoned(() async { + final appName = 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final testAuth = Auth(app); + + // Helper function to exchange custom token for ID token + Future getIdTokenFromCustomToken(String customToken) async { + final client = await testAuth.app.client; + final api = IdentityToolkitApi(client); + + final request = + GoogleCloudIdentitytoolkitV1SignInWithCustomTokenRequest( + token: customToken, + returnSecureToken: true, + ); + + final response = await api.accounts.signInWithCustomToken(request); + + if (response.idToken == null || response.idToken!.isEmpty) { + throw Exception( + 'Failed to exchange custom token for ID token: No idToken in response', + ); + } + + return response.idToken!; + } + + UserRecord? user; + try { + user = await testAuth.createUser(CreateRequest(uid: _uid.v4())); + + final customToken = await testAuth.createCustomToken(user.uid); + final idToken = await getIdTokenFromCustomToken(customToken); + + await Future.delayed(const Duration(seconds: 2)); + await testAuth.revokeRefreshTokens(user.uid); + + const expiresIn = 24 * 60 * 60 * 1000; + await expectLater( + () => testAuth.createSessionCookie( + idToken, + const SessionCookieOptions(expiresIn: expiresIn), + ), + throwsA(isA()), + ); + } finally { + if (user != null) { + await testAuth.deleteUser(user.uid); + } + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv}); + }, + skip: hasGoogleEnv + ? false + : 'Session cookies require GCIP (not available in emulator)', + ); + + test( + 'verifySessionCookie rejects invalid session cookie', + () { + // Remove emulator env var from the zone environment + final prodEnv = Map.from(Platform.environment); + prodEnv.remove(Environment.firebaseAuthEmulatorHost); + + return runZoned(() async { + final appName = 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final testAuth = Auth(app); + + try { + await expectLater( + () => testAuth.verifySessionCookie('invalid-session-cookie'), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/argument-error', + ), + ), + ); + } finally { + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv}); + }, + skip: hasGoogleEnv + ? false + : 'Session cookies require GCIP (not available in emulator)', + ); + }); + + group('getUsers (Production)', () { + test( + 'gets multiple users by different identifiers', + () { + // Remove emulator env var from the zone environment + final prodEnv = Map.from(Platform.environment); + prodEnv.remove(Environment.firebaseAuthEmulatorHost); + + return runZoned(() async { + final appName = 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final testAuth = Auth(app); + + UserRecord? user1; + UserRecord? user2; + try { + user1 = await testAuth.createUser( + CreateRequest( + uid: _uid.v4(), + email: 'user1-${_uid.v4()}@example.com', + ), + ); + user2 = await testAuth.createUser( + CreateRequest( + uid: _uid.v4(), + phoneNumber: + '+1${DateTime.now().millisecondsSinceEpoch % 10000000000}', + ), + ); + + final result = await testAuth.getUsers([ + UidIdentifier(uid: user1.uid), + EmailIdentifier(email: user1.email!), + UidIdentifier(uid: user2.uid), + ]); + + expect(result.users.length, greaterThanOrEqualTo(2)); + expect(result.users.map((u) => u.uid), contains(user1.uid)); + expect(result.users.map((u) => u.uid), contains(user2.uid)); + } finally { + await Future.wait([ + if (user1 != null) testAuth.deleteUser(user1.uid), + if (user2 != null) testAuth.deleteUser(user2.uid), + ]); + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv}); + }, + skip: hasGoogleEnv + ? false + : 'getUsers not fully supported in Firebase Auth Emulator', + ); + + test( + 'reports not found users', + () { + // Remove emulator env var from the zone environment + final prodEnv = Map.from(Platform.environment); + prodEnv.remove(Environment.firebaseAuthEmulatorHost); + + return runZoned(() async { + final appName = 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final testAuth = Auth(app); + + UserRecord? user1; + try { + user1 = await testAuth.createUser(CreateRequest(uid: _uid.v4())); + + final result = await testAuth.getUsers([ + UidIdentifier(uid: user1.uid), + UidIdentifier(uid: 'non-existent-uid'), + EmailIdentifier(email: 'nonexistent@example.com'), + ]); + + expect(result.users, isNotEmpty); + expect(result.users.map((u) => u.uid), contains(user1.uid)); + expect(result.notFound, isNotEmpty); + } finally { + if (user1 != null) { + await testAuth.deleteUser(user1.uid); + } + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv}); + }, + skip: hasGoogleEnv + ? false + : 'getUsers not fully supported in Firebase Auth Emulator', + ); + }); + + group('createProviderConfig (Production)', () { + // Note: These tests create their own Auth instances inside runZoned + // to ensure the zone environment stays active during test execution. + + // Note: OIDC provider configs require GCIP (Google Cloud Identity Platform) + // and are not available in the Auth Emulator. This test wraps the test body + // in runZoned to ensure the zone environment (without emulator) stays active. + test( + 'creates OIDC provider config successfully', + () { + // Remove emulator env var from the zone environment + final prodEnv = Map.from(Platform.environment); + prodEnv.remove(Environment.firebaseAuthEmulatorHost); + + return runZoned(() async { + final appName = 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final testAuth = Auth(app); + + try { + final oidcConfig = OIDCAuthProviderConfig( + providerId: 'oidc.test-provider', + displayName: 'Test OIDC Provider', + enabled: true, + clientId: 'TEST_CLIENT_ID', + issuer: 'https://oidc.example.com/issuer', + clientSecret: 'TEST_CLIENT_SECRET', + ); + + final createdConfig = await testAuth.createProviderConfig( + oidcConfig, + ); + + expect(createdConfig, isA()); + expect(createdConfig.providerId, equals('oidc.test-provider')); + expect(createdConfig.displayName, equals('Test OIDC Provider')); + expect(createdConfig.enabled, isTrue); + + await testAuth.deleteProviderConfig('oidc.test-provider'); + } finally { + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv}); + }, + skip: hasGoogleEnv + ? false + : 'Provider configs require GCIP (not available in emulator)', + ); + + // Note: SAML provider configs require GCIP (Google Cloud Identity Platform) + // and are not available in the Auth Emulator. This test wraps the test body + // in runZoned to ensure the zone environment (without emulator) stays active. + test( + 'creates SAML provider config successfully', + () { + // Remove emulator env var from the zone environment + final prodEnv = Map.from(Platform.environment); + prodEnv.remove(Environment.firebaseAuthEmulatorHost); + + return runZoned(() async { + final appName = 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final testAuth = Auth(app); + + try { + final samlConfig = SAMLAuthProviderConfig( + providerId: 'saml.test-provider', + displayName: 'Test SAML Provider', + enabled: true, + idpEntityId: 'TEST_IDP_ENTITY_ID', + ssoURL: 'https://example.com/login', + x509Certificates: ['TEST_CERT'], + rpEntityId: 'TEST_RP_ENTITY_ID', + callbackURL: 'https://project-id.firebaseapp.com/__/auth/handler', + ); + + final createdConfig = await testAuth.createProviderConfig( + samlConfig, + ); + + expect(createdConfig, isA()); + expect(createdConfig.providerId, equals('saml.test-provider')); + expect(createdConfig.displayName, equals('Test SAML Provider')); + expect(createdConfig.enabled, isTrue); + + await testAuth.deleteProviderConfig('saml.test-provider'); + } finally { + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv}); + }, + skip: hasGoogleEnv + ? false + : 'Provider configs require GCIP (not available in emulator)', + ); + }); +} diff --git a/packages/dart_firebase_admin/test/auth/auth_test.dart b/packages/dart_firebase_admin/test/auth/auth_test.dart index 2c537be5..73808626 100644 --- a/packages/dart_firebase_admin/test/auth/auth_test.dart +++ b/packages/dart_firebase_admin/test/auth/auth_test.dart @@ -1,46 +1,15 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:dart_firebase_admin/auth.dart'; +import 'package:dart_firebase_admin/src/app.dart'; +import 'package:googleapis/identitytoolkit/v1.dart'; import 'package:http/http.dart'; import 'package:mocktail/mocktail.dart'; -import 'package:path/path.dart' as p; import 'package:test/test.dart'; import '../google_cloud_firestore/util/helpers.dart'; import '../mock.dart'; -Future run( - String executable, - List arguments, { - String? workDir, -}) async { - final process = await Process.run( - executable, - arguments, - stdoutEncoding: utf8, - workingDirectory: workDir, - ); - - if (process.exitCode != 0) { - throw Exception(process.stderr); - } - - return process; -} - -Future npmInstall({String? workDir}) async => - run('npm', ['install'], workDir: workDir); - -/// Run test/client/get_id_token.js -Future getIdToken() async { - final path = p.join(Directory.current.path, 'test', 'client'); - - await npmInstall(workDir: path); - - final process = await run('node', ['get_id_token.js'], workDir: path); - - return (process.stdout as String).trim(); -} - void main() { late Auth auth; @@ -55,23 +24,78 @@ void main() { group('verifyIdToken', () { test( 'verifies ID token from Firebase Auth production', - () async { - final app = createApp(); - final authProd = Auth(app); - - final token = await getIdToken(); - final decodedToken = await authProd.verifyIdToken(token); - - expect(decodedToken.aud, 'dart-firebase-admin'); - expect(decodedToken.uid, 'TmpgnnHo3JRjzQZjgBaYzQDyyZi2'); - expect(decodedToken.sub, 'TmpgnnHo3JRjzQZjgBaYzQDyyZi2'); - expect(decodedToken.email, 'foo@google.com'); - expect(decodedToken.emailVerified, false); - expect(decodedToken.phoneNumber, isNull); - expect(decodedToken.firebase.identities, { - 'email': ['foo@google.com'], - }); - expect(decodedToken.firebase.signInProvider, 'password'); + () { + // Remove emulator env var from the zone environment + final prodEnv = Map.from(Platform.environment); + prodEnv.remove(Environment.firebaseAuthEmulatorHost); + + return runZoned(() async { + final appName = + 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final authProd = Auth(app); + + try { + // Helper function to exchange custom token for ID token + Future getIdTokenFromCustomToken( + String customToken, + ) async { + final client = await authProd.app.client; + final api = IdentityToolkitApi(client); + + final request = + GoogleCloudIdentitytoolkitV1SignInWithCustomTokenRequest( + token: customToken, + returnSecureToken: true, + ); + + final response = await api.accounts.signInWithCustomToken( + request, + ); + + if (response.idToken == null || response.idToken!.isEmpty) { + throw Exception( + 'Failed to exchange custom token for ID token: No idToken in response', + ); + } + + return response.idToken!; + } + + // Create a user and get ID token + const email = 'foo@google.com'; + const password = + 'TestPassword123!'; // Meets all password requirements + UserRecord? user; + try { + user = await authProd.createUser( + CreateRequest(email: email, password: password), + ); + + final customToken = await authProd.createCustomToken(user.uid); + final token = await getIdTokenFromCustomToken(customToken); + final decodedToken = await authProd.verifyIdToken(token); + + expect(decodedToken.aud, 'dart-firebase-admin'); + expect(decodedToken.uid, user.uid); + expect(decodedToken.sub, user.uid); + expect(decodedToken.email, email); + expect(decodedToken.emailVerified, false); + expect(decodedToken.phoneNumber, isNull); + expect(decodedToken.firebase.identities, { + 'email': [email], + }); + // When signing in with custom token, signInProvider is 'custom' + expect(decodedToken.firebase.signInProvider, 'custom'); + } finally { + if (user != null) { + await authProd.deleteUser(user.uid); + } + } + } finally { + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv}); }, skip: hasGoogleEnv ? false @@ -116,6 +140,43 @@ void main() { ); }); + test('generates link with ActionCodeSettings', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'oobLink': 'https://example.com/reset?oobCode=ABC123', + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-reset-link-settings', + ); + final testAuth = Auth(app); + + final actionCodeSettings = ActionCodeSettings( + url: 'https://myapp.example.com/finishReset', + ); + + final link = await testAuth.generatePasswordResetLink( + 'test@example.com', + actionCodeSettings: actionCodeSettings, + ); + + expect(link, equals('https://example.com/reset?oobCode=ABC123')); + verify(() => clientMock.send(any())).called(1); + }); + test('validates ActionCodeSettings.url is a valid URI', () async { final actionCodeSettings = ActionCodeSettings(url: 'not a valid url'); @@ -198,6 +259,72 @@ void main() { }); group('generateEmailVerificationLink', () { + test('generates link without ActionCodeSettings', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'oobLink': 'https://example.com/verify?oobCode=XYZ789', + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp(client: clientMock, name: 'test-verify-link'); + final testAuth = Auth(app); + + final link = await testAuth.generateEmailVerificationLink( + 'test@example.com', + ); + + expect(link, equals('https://example.com/verify?oobCode=XYZ789')); + verify(() => clientMock.send(any())).called(1); + }); + + test('generates link with ActionCodeSettings', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'oobLink': 'https://example.com/verify?oobCode=XYZ789', + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-verify-link-settings', + ); + final testAuth = Auth(app); + + final actionCodeSettings = ActionCodeSettings( + url: 'https://myapp.example.com/finishVerification', + ); + + final link = await testAuth.generateEmailVerificationLink( + 'test@example.com', + actionCodeSettings: actionCodeSettings, + ); + + expect(link, equals('https://example.com/verify?oobCode=XYZ789')); + verify(() => clientMock.send(any())).called(1); + }); + test('generates link with linkDomain (new property)', () async { final clientMock = ClientMock(); @@ -361,6 +488,44 @@ void main() { verify(() => clientMock.send(any())).called(1); }); + test('generates link with ActionCodeSettings', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'oobLink': 'https://example.com/signin?oobCode=DEF456', + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-signin-link-settings', + ); + final testAuth = Auth(app); + + final actionCodeSettings = ActionCodeSettings( + url: 'https://myapp.example.com/finishSignIn', + handleCodeInApp: true, + ); + + final link = await testAuth.generateSignInWithEmailLink( + 'test@example.com', + actionCodeSettings, + ); + + expect(link, equals('https://example.com/signin?oobCode=DEF456')); + verify(() => clientMock.send(any())).called(1); + }); + test('validates email is required', () async { final actionCodeSettings = ActionCodeSettings( url: 'https://example.com', @@ -372,9 +537,68 @@ void main() { throwsA(isA()), ); }); + + test('validates ActionCodeSettings.linkDomain is not empty', () { + final actionCodeSettings = ActionCodeSettings( + url: 'https://example.com', + handleCodeInApp: true, + linkDomain: '', + ); + + expect( + () => auth.generateSignInWithEmailLink( + 'test@example.com', + actionCodeSettings, + ), + throwsA( + isA().having( + (e) => e.errorCode, + 'errorCode', + AuthClientErrorCode.invalidHostingLinkDomain, + ), + ), + ); + }); }); group('generateVerifyAndChangeEmailLink', () { + test('generates link without ActionCodeSettings', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'oobLink': + 'https://example.com/changeEmail?oobCode=GHI789', + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-change-email-link-basic', + ); + final testAuth = Auth(app); + + final link = await testAuth.generateVerifyAndChangeEmailLink( + 'old@example.com', + 'new@example.com', + ); + + expect( + link, + equals('https://example.com/changeEmail?oobCode=GHI789'), + ); + verify(() => clientMock.send(any())).called(1); + }); + test('generates link with ActionCodeSettings', () async { final clientMock = ClientMock(); @@ -491,7 +715,3453 @@ void main() { throwsA(isA()), ); }); + + test('validates ActionCodeSettings.linkDomain is not empty', () { + final actionCodeSettings = ActionCodeSettings( + url: 'https://example.com', + linkDomain: '', + ); + + expect( + () => auth.generateVerifyAndChangeEmailLink( + 'old@example.com', + 'new@example.com', + actionCodeSettings: actionCodeSettings, + ), + throwsA( + isA().having( + (e) => e.errorCode, + 'errorCode', + AuthClientErrorCode.invalidHostingLinkDomain, + ), + ), + ); + }); + }); + }); + + group('createCustomToken', () { + test( + 'creates a valid JWT token', + () async { + final token = await auth.createCustomToken('test-uid'); + + expect(token, isNotEmpty); + expect(token, isA()); + // Token should be in JWT format (3 parts separated by dots) + expect(token.split('.').length, equals(3)); + }, + skip: hasGoogleEnv + ? false + : 'Requires GOOGLE_APPLICATION_CREDENTIALS for service account', + ); + + test( + 'creates token with developer claims', + () async { + final token = await auth.createCustomToken( + 'test-uid', + developerClaims: {'admin': true, 'level': 5}, + ); + + expect(token, isNotEmpty); + expect(token, isA()); + }, + skip: hasGoogleEnv + ? false + : 'Requires GOOGLE_APPLICATION_CREDENTIALS for service account', + ); + + test('throws when uid is empty', () async { + expect( + () => auth.createCustomToken(''), + throwsA(isA()), + ); }); }); + + group('setCustomUserClaims', () { + test('sets custom claims for user', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value(utf8.encode(jsonEncode({'localId': 'test-uid'}))), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp(client: clientMock, name: 'test-set-claims'); + final testAuth = Auth(app); + + await testAuth.setCustomUserClaims( + 'test-uid', + customUserClaims: {'admin': true, 'role': 'editor'}, + ); + + verify(() => clientMock.send(any())).called(1); + }); + + test('throws when uid is empty', () async { + await expectLater( + () => auth.setCustomUserClaims('', customUserClaims: {'admin': true}), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/invalid-uid', + ), + ), + ); + }); + + test('throws when uid is invalid (too long)', () async { + final invalidUid = 'a' * 129; // UID must be <= 128 characters + await expectLater( + () => auth.setCustomUserClaims( + invalidUid, + customUserClaims: {'admin': true}, + ), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/invalid-uid', + ), + ), + ); + }); + + test('clears claims when null is passed', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value(utf8.encode(jsonEncode({'localId': 'test-uid'}))), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp(client: clientMock, name: 'test-clear-claims'); + final testAuth = Auth(app); + + await testAuth.setCustomUserClaims('test-uid'); + + verify(() => clientMock.send(any())).called(1); + }); + + test('throws error when backend returns error', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'error': {'code': 404, 'message': 'USER_NOT_FOUND'}, + }), + ), + ), + 404, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-set-claims-error', + ); + final testAuth = Auth(app); + + await expectLater( + testAuth.setCustomUserClaims( + 'test-uid', + customUserClaims: {'admin': true}, + ), + throwsA(isA()), + ); + + verify(() => clientMock.send(any())).called(1); + }); + }); + + group('revokeRefreshTokens', () { + test('revokes refresh tokens successfully', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'localId': 'test-uid', + 'validSince': + '${DateTime.now().millisecondsSinceEpoch ~/ 1000}', + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp(client: clientMock, name: 'test-revoke-tokens'); + final testAuth = Auth(app); + + await testAuth.revokeRefreshTokens('test-uid'); + + verify(() => clientMock.send(any())).called(1); + }); + + test('throws when uid is empty', () async { + await expectLater( + () => auth.revokeRefreshTokens(''), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/invalid-uid', + ), + ), + ); + }); + + test('throws when uid is invalid (too long)', () async { + final invalidUid = 'a' * 129; // UID must be <= 128 characters + await expectLater( + () => auth.revokeRefreshTokens(invalidUid), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/invalid-uid', + ), + ), + ); + }); + + test('throws error when backend returns error', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'error': {'code': 404, 'message': 'USER_NOT_FOUND'}, + }), + ), + ), + 404, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-revoke-tokens-error', + ); + final testAuth = Auth(app); + + await expectLater( + testAuth.revokeRefreshTokens('test-uid'), + throwsA(isA()), + ); + + verify(() => clientMock.send(any())).called(1); + }); + }); + + group('deleteUser', () { + test('deletes user successfully', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({'kind': 'identitytoolkit#DeleteAccountResponse'}), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp(client: clientMock, name: 'test-delete-user'); + final testAuth = Auth(app); + + await testAuth.deleteUser('test-uid'); + + verify(() => clientMock.send(any())).called(1); + }); + + test('throws when uid is empty', () async { + expect( + () => auth.deleteUser(''), + throwsA(isA()), + ); + }); + + test('throws when uid is invalid (too long)', () async { + // UID must be 128 characters or less + final invalidUid = 'a' * 129; + await expectLater( + () => auth.deleteUser(invalidUid), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/invalid-uid', + ), + ), + ); + }); + + test('throws error when backend returns error', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'error': {'code': 404, 'message': 'USER_NOT_FOUND'}, + }), + ), + ), + 404, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-delete-user-error', + ); + final testAuth = Auth(app); + + await expectLater( + testAuth.deleteUser('non-existent-uid'), + throwsA(isA()), + ); + + verify(() => clientMock.send(any())).called(1); + }); + }); + + group('deleteUsers', () { + test('deletes multiple users successfully', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value(utf8.encode(jsonEncode({'errors': []}))), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp(client: clientMock, name: 'test-delete-users'); + final testAuth = Auth(app); + + final result = await testAuth.deleteUsers(['uid1', 'uid2', 'uid3']); + + expect(result.successCount, equals(3)); + expect(result.failureCount, equals(0)); + expect(result.errors, isEmpty); + verify(() => clientMock.send(any())).called(1); + }); + + test('handles errors for some users', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'errors': [ + {'index': 1, 'message': 'USER_NOT_FOUND'}, + ], + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-delete-users-errors', + ); + final testAuth = Auth(app); + + final result = await testAuth.deleteUsers(['uid1', 'uid2', 'uid3']); + + expect(result.successCount, equals(2)); + expect(result.failureCount, equals(1)); + expect(result.errors, hasLength(1)); + verify(() => clientMock.send(any())).called(1); + }); + + test('handles empty array', () async { + final result = await auth.deleteUsers([]); + + expect(result.successCount, equals(0)); + expect(result.failureCount, equals(0)); + expect(result.errors, isEmpty); + }); + + test('throws when uids list exceeds maximum limit', () async { + // Maximum is 1000 uids + final tooManyUids = List.generate(1001, (i) => 'uid$i'); + + await expectLater( + () => auth.deleteUsers(tooManyUids), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/maximum-user-count-exceeded', + ), + ), + ); + }); + + test('handles multiple errors with correct indexing', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'errors': [ + {'index': 0, 'message': 'USER_NOT_FOUND'}, + {'index': 2, 'message': 'INTERNAL_ERROR'}, + ], + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-delete-users-multiple-errors', + ); + final testAuth = Auth(app); + + final result = await testAuth.deleteUsers([ + 'uid1', + 'uid2', + 'uid3', + 'uid4', + ]); + + expect(result.successCount, equals(2)); + expect(result.failureCount, equals(2)); + expect(result.errors, hasLength(2)); + expect(result.errors[0].index, equals(0)); + expect(result.errors[1].index, equals(2)); + verify(() => clientMock.send(any())).called(1); + }); + }); + + group('listUsers', () { + test('lists users successfully', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'users': [ + { + 'localId': 'uid1', + 'email': 'user1@example.com', + 'emailVerified': false, + 'disabled': false, + 'createdAt': '1234567890000', + }, + { + 'localId': 'uid2', + 'email': 'user2@example.com', + 'emailVerified': true, + 'disabled': false, + 'createdAt': '1234567890000', + }, + ], + 'nextPageToken': 'next-page-token', + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp(client: clientMock, name: 'test-list-users'); + final testAuth = Auth(app); + + final result = await testAuth.listUsers(); + + expect(result.users, hasLength(2)); + expect(result.users[0].uid, equals('uid1')); + expect(result.users[1].uid, equals('uid2')); + expect(result.pageToken, equals('next-page-token')); + verify(() => clientMock.send(any())).called(1); + }); + + test('supports pagination parameters', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value(utf8.encode(jsonEncode({'users': []}))), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-list-users-pagination', + ); + final testAuth = Auth(app); + + await testAuth.listUsers(maxResults: 10, pageToken: 'page-token'); + + verify(() => clientMock.send(any())).called(1); + }); + + test('lists users with default options', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'users': [ + { + 'localId': 'uid1', + 'email': 'user1@example.com', + 'emailVerified': false, + 'disabled': false, + 'createdAt': '1234567890000', + }, + ], + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-list-users-default', + ); + final testAuth = Auth(app); + + final result = await testAuth.listUsers(); + + expect(result.users, hasLength(1)); + expect(result.users[0].uid, equals('uid1')); + verify(() => clientMock.send(any())).called(1); + }); + + test('returns empty list when no users exist', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value(utf8.encode(jsonEncode({'users': []}))), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-list-users-empty', + ); + final testAuth = Auth(app); + + final result = await testAuth.listUsers(maxResults: 500); + + expect(result.users, isEmpty); + expect(result.pageToken, isNull); + verify(() => clientMock.send(any())).called(1); + }); + + test('throws error when backend returns error', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'error': {'code': 500, 'message': 'INTERNAL_ERROR'}, + }), + ), + ), + 500, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-list-users-error', + ); + final testAuth = Auth(app); + + await expectLater( + testAuth.listUsers(maxResults: 500), + throwsA(isA()), + ); + + verify(() => clientMock.send(any())).called(1); + }); + }); + + group('getUsers', () { + test('gets multiple users by identifiers', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'users': [ + { + 'localId': 'uid1', + 'email': 'user1@example.com', + 'emailVerified': false, + 'disabled': false, + 'createdAt': '1234567890000', + }, + { + 'localId': 'uid2', + 'phoneNumber': '+1234567890', + 'emailVerified': false, + 'disabled': false, + 'createdAt': '1234567890000', + }, + ], + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp(client: clientMock, name: 'test-get-users'); + final testAuth = Auth(app); + + final result = await testAuth.getUsers([ + UidIdentifier(uid: 'uid1'), + EmailIdentifier(email: 'user1@example.com'), + UidIdentifier(uid: 'uid2'), + ]); + + expect(result.users, hasLength(2)); + expect(result.users[0].uid, equals('uid1')); + expect(result.users[1].uid, equals('uid2')); + verify(() => clientMock.send(any())).called(1); + }); + + test('handles empty identifiers array', () async { + final result = await auth.getUsers([]); + + expect(result.users, isEmpty); + expect(result.notFound, isEmpty); + }); + + test( + 'returns no users when given identifiers that do not exist', + () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value(utf8.encode(jsonEncode({}))), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-get-users-not-found', + ); + final testAuth = Auth(app); + + final notFoundIds = [UidIdentifier(uid: 'id-that-doesnt-exist')]; + final result = await testAuth.getUsers(notFoundIds); + + expect(result.users, isEmpty); + expect(result.notFound, equals(notFoundIds)); + verify(() => clientMock.send(any())).called(1); + }, + ); + + test('throws when identifiers list exceeds maximum limit', () { + // Maximum is 100 identifiers + final tooManyIdentifiers = List.generate( + 101, + (i) => UidIdentifier(uid: 'uid$i'), + ); + + expect( + () => auth.getUsers(tooManyIdentifiers), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/maximum-user-count-exceeded', + ), + ), + ); + }); + + test( + 'returns users by various identifier types including provider', + () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'users': [ + { + 'localId': 'uid1', + 'email': 'user1@example.com', + 'phoneNumber': '+15555550001', + 'emailVerified': false, + 'disabled': false, + 'createdAt': '1234567890000', + }, + { + 'localId': 'uid2', + 'email': 'user2@example.com', + 'phoneNumber': '+15555550002', + 'emailVerified': false, + 'disabled': false, + 'createdAt': '1234567890000', + }, + { + 'localId': 'uid3', + 'email': 'user3@example.com', + 'phoneNumber': '+15555550003', + 'emailVerified': false, + 'disabled': false, + 'createdAt': '1234567890000', + }, + { + 'localId': 'uid4', + 'email': 'user4@example.com', + 'phoneNumber': '+15555550004', + 'emailVerified': false, + 'disabled': false, + 'createdAt': '1234567890000', + 'providerUserInfo': [ + { + 'providerId': 'google.com', + 'rawId': 'google_uid4', + }, + ], + }, + ], + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-get-users-various-types', + ); + final testAuth = Auth(app); + + final identifiers = [ + UidIdentifier(uid: 'uid1'), + EmailIdentifier(email: 'user2@example.com'), + PhoneIdentifier(phoneNumber: '+15555550003'), + ProviderIdentifier( + providerId: 'google.com', + providerUid: 'google_uid4', + ), + UidIdentifier(uid: 'this-user-doesnt-exist'), + ]; + + final result = await testAuth.getUsers(identifiers); + + expect(result.users, hasLength(4)); + // Check that the non-existent uid is in notFound + expect(result.notFound, isNotEmpty); + final notFoundUid = result.notFound + .whereType() + .where((id) => id.uid == 'this-user-doesnt-exist') + .firstOrNull; + expect(notFoundUid, isNotNull); + expect(notFoundUid!.uid, equals('this-user-doesnt-exist')); + verify(() => clientMock.send(any())).called(1); + }, + ); + }); + + group('getUser', () { + test('gets user successfully', () async { + const testUid = 'test-uid-123'; + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'users': [ + { + 'localId': testUid, + 'email': 'test@example.com', + 'displayName': 'Test User', + 'emailVerified': false, + 'disabled': false, + 'createdAt': '1234567890000', + }, + ], + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp(client: clientMock, name: 'test-get-user'); + final testAuth = Auth(app); + + final user = await testAuth.getUser(testUid); + + expect(user.uid, equals(testUid)); + expect(user.email, equals('test@example.com')); + expect(user.displayName, equals('Test User')); + expect(user.emailVerified, isFalse); + expect(user.disabled, isFalse); + verify(() => clientMock.send(any())).called(1); + }); + + test('throws when uid is empty', () async { + await expectLater( + () => auth.getUser(''), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/invalid-uid', + ), + ), + ); + }); + + test('throws when uid is invalid (too long)', () async { + final invalidUid = 'a' * 129; // UID must be <= 128 characters + await expectLater( + () => auth.getUser(invalidUid), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/invalid-uid', + ), + ), + ); + }); + + test('throws error when backend returns error', () async { + const testUid = 'test-uid-123'; + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'error': {'code': 404, 'message': 'USER_NOT_FOUND'}, + }), + ), + ), + 404, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp(client: clientMock, name: 'test-get-user-error'); + final testAuth = Auth(app); + + await expectLater( + testAuth.getUser(testUid), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/user-not-found', + ), + ), + ); + + verify(() => clientMock.send(any())).called(1); + }); + }); + + group('getUserByEmail', () { + test('gets user by email successfully', () async { + const testEmail = 'user@example.com'; + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'users': [ + { + 'localId': 'test-uid-123', + 'email': testEmail, + 'displayName': 'Test User', + 'emailVerified': false, + 'disabled': false, + 'createdAt': '1234567890000', + }, + ], + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-get-user-by-email', + ); + final testAuth = Auth(app); + + final user = await testAuth.getUserByEmail(testEmail); + + expect(user.uid, equals('test-uid-123')); + expect(user.email, equals(testEmail)); + expect(user.displayName, equals('Test User')); + expect(user.emailVerified, isFalse); + expect(user.disabled, isFalse); + verify(() => clientMock.send(any())).called(1); + }); + + test('throws when email is empty', () async { + await expectLater( + () => auth.getUserByEmail(''), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/invalid-email', + ), + ), + ); + }); + + test('throws when email is invalid', () async { + const invalidEmail = 'name-example-com'; + await expectLater( + () => auth.getUserByEmail(invalidEmail), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/invalid-email', + ), + ), + ); + }); + + test('throws error when backend returns error', () async { + const testEmail = 'user@example.com'; + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'error': {'code': 404, 'message': 'USER_NOT_FOUND'}, + }), + ), + ), + 404, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-get-user-by-email-error', + ); + final testAuth = Auth(app); + + await expectLater( + testAuth.getUserByEmail(testEmail), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/user-not-found', + ), + ), + ); + + verify(() => clientMock.send(any())).called(1); + }); + }); + + group('getUserByPhoneNumber', () { + test('gets user by phone number successfully', () async { + const testPhoneNumber = '+11234567890'; + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'users': [ + { + 'localId': 'test-uid-123', + 'phoneNumber': testPhoneNumber, + 'emailVerified': false, + 'disabled': false, + 'createdAt': '1234567890000', + }, + ], + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-get-user-by-phone', + ); + final testAuth = Auth(app); + + final user = await testAuth.getUserByPhoneNumber(testPhoneNumber); + + expect(user.uid, equals('test-uid-123')); + expect(user.phoneNumber, equals(testPhoneNumber)); + expect(user.emailVerified, isFalse); + expect(user.disabled, isFalse); + verify(() => clientMock.send(any())).called(1); + }); + + test('throws when phone number is empty', () async { + await expectLater( + () => auth.getUserByPhoneNumber(''), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/invalid-phone-number', + ), + ), + ); + }); + + test('throws when phone number is invalid', () async { + const invalidPhoneNumber = 'invalid'; + await expectLater( + () => auth.getUserByPhoneNumber(invalidPhoneNumber), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/invalid-phone-number', + ), + ), + ); + }); + + test('throws error when backend returns error', () async { + const testPhoneNumber = '+11234567890'; + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'error': {'code': 404, 'message': 'USER_NOT_FOUND'}, + }), + ), + ), + 404, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-get-user-by-phone-error', + ); + final testAuth = Auth(app); + + await expectLater( + testAuth.getUserByPhoneNumber(testPhoneNumber), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/user-not-found', + ), + ), + ); + + verify(() => clientMock.send(any())).called(1); + }); + }); + + group('getUserByProviderUid', () { + test('gets user by provider uid successfully', () async { + const providerId = 'google.com'; + const providerUid = 'google_uid'; + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'users': [ + { + 'localId': 'test-uid-123', + 'email': 'user@example.com', + 'emailVerified': false, + 'disabled': false, + 'createdAt': '1234567890000', + }, + ], + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-get-user-by-provider-uid', + ); + final testAuth = Auth(app); + + final user = await testAuth.getUserByProviderUid( + providerId: providerId, + uid: providerUid, + ); + + expect(user.uid, equals('test-uid-123')); + expect(user.email, equals('user@example.com')); + verify(() => clientMock.send(any())).called(1); + }); + + test('throws when provider ID is empty', () { + expect( + () => auth.getUserByProviderUid(providerId: '', uid: 'uid'), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/invalid-provider-id', + ), + ), + ); + }); + + test('throws invalid-uid when uid is empty', () { + expect( + () => auth.getUserByProviderUid(providerId: 'google.com', uid: ''), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/invalid-uid', + ), + ), + ); + }); + + test( + 'redirects to getUserByPhoneNumber when providerId is phone', + () async { + const phoneNumber = '+11234567890'; + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'users': [ + { + 'localId': 'test-uid-123', + 'phoneNumber': phoneNumber, + 'emailVerified': false, + 'disabled': false, + 'createdAt': '1234567890000', + }, + ], + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-get-user-by-phone-provider', + ); + final testAuth = Auth(app); + + final user = await testAuth.getUserByProviderUid( + providerId: 'phone', + uid: phoneNumber, + ); + + expect(user.uid, equals('test-uid-123')); + expect(user.phoneNumber, equals(phoneNumber)); + verify(() => clientMock.send(any())).called(1); + }, + ); + + test('redirects to getUserByEmail when providerId is email', () async { + const email = 'user@example.com'; + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'users': [ + { + 'localId': 'test-uid-123', + 'email': email, + 'emailVerified': false, + 'disabled': false, + 'createdAt': '1234567890000', + }, + ], + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-get-user-by-email-provider', + ); + final testAuth = Auth(app); + + final user = await testAuth.getUserByProviderUid( + providerId: 'email', + uid: email, + ); + + expect(user.uid, equals('test-uid-123')); + expect(user.email, equals(email)); + verify(() => clientMock.send(any())).called(1); + }); + + test('throws error when backend returns error', () async { + const providerId = 'google.com'; + const providerUid = 'google_uid'; + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'error': {'code': 404, 'message': 'USER_NOT_FOUND'}, + }), + ), + ), + 404, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-get-user-by-provider-uid-error', + ); + final testAuth = Auth(app); + + await expectLater( + testAuth.getUserByProviderUid( + providerId: providerId, + uid: providerUid, + ), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/user-not-found', + ), + ), + ); + + verify(() => clientMock.send(any())).called(1); + }); + }); + + group('importUsers', () { + test('imports users successfully', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value(utf8.encode(jsonEncode({'error': []}))), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp(client: clientMock, name: 'test-import-users'); + final testAuth = Auth(app); + + final users = [ + UserImportRecord(uid: 'uid1', email: 'user1@example.com'), + UserImportRecord(uid: 'uid2', email: 'user2@example.com'), + ]; + + final result = await testAuth.importUsers(users); + + expect(result.successCount, equals(2)); + expect(result.failureCount, equals(0)); + expect(result.errors, isEmpty); + verify(() => clientMock.send(any())).called(1); + }); + + test('handles partial failures', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'error': [ + {'index': 1, 'message': 'INVALID_PHONE_NUMBER'}, + ], + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-import-users-partial', + ); + final testAuth = Auth(app); + + final users = [ + UserImportRecord(uid: 'uid1', email: 'user1@example.com'), + UserImportRecord(uid: 'uid2', phoneNumber: 'invalid'), + ]; + + final result = await testAuth.importUsers(users); + + expect(result.successCount, equals(1)); + expect(result.failureCount, equals(1)); + expect(result.errors, hasLength(1)); + expect(result.errors[0].index, equals(1)); + verify(() => clientMock.send(any())).called(1); + }); + + test('throws error when backend returns error', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'error': {'code': 500, 'message': 'INTERNAL_ERROR'}, + }), + ), + ), + 500, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-import-users-error', + ); + final testAuth = Auth(app); + + final users = [ + UserImportRecord(uid: 'uid1', email: 'user1@example.com'), + ]; + + await expectLater( + testAuth.importUsers(users), + throwsA(isA()), + ); + + verify(() => clientMock.send(any())).called(1); + }); + }); + + group('listProviderConfigs', () { + test('lists OIDC provider configs successfully', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'oauthIdpConfigs': [ + { + 'name': + 'projects/project_id/oauthIdpConfigs/oidc.provider1', + 'displayName': 'OIDC Provider 1', + 'enabled': true, + 'clientId': 'CLIENT_ID_1', + 'issuer': 'https://oidc1.com/issuer', + }, + { + 'name': + 'projects/project_id/oauthIdpConfigs/oidc.provider2', + 'displayName': 'OIDC Provider 2', + 'enabled': true, + 'clientId': 'CLIENT_ID_2', + 'issuer': 'https://oidc2.com/issuer', + }, + ], + 'nextPageToken': 'NEXT_PAGE_TOKEN', + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-list-oidc-configs', + ); + final testAuth = Auth(app); + + final result = await testAuth.listProviderConfigs( + AuthProviderConfigFilter.oidc( + maxResults: 50, + pageToken: 'PAGE_TOKEN', + ), + ); + + expect(result.providerConfigs, hasLength(2)); + expect(result.providerConfigs[0], isA()); + expect(result.providerConfigs[0].providerId, equals('oidc.provider1')); + expect(result.providerConfigs[1].providerId, equals('oidc.provider2')); + expect(result.pageToken, equals('NEXT_PAGE_TOKEN')); + verify(() => clientMock.send(any())).called(1); + }); + + test('lists SAML provider configs successfully', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'inboundSamlConfigs': [ + { + 'name': + 'projects/project_id/inboundSamlConfigs/saml.provider1', + 'idpConfig': { + 'idpEntityId': 'IDP_ENTITY_ID_1', + 'ssoUrl': 'https://saml1.com/login', + 'idpCertificates': [ + {'x509Certificate': 'CERT1'}, + ], + }, + 'spConfig': { + 'spEntityId': 'RP_ENTITY_ID_1', + 'callbackUri': + 'https://project-id.firebaseapp.com/__/auth/handler', + }, + 'displayName': 'SAML Provider 1', + 'enabled': true, + }, + ], + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-list-saml-configs', + ); + final testAuth = Auth(app); + + final result = await testAuth.listProviderConfigs( + AuthProviderConfigFilter.saml(), + ); + + expect(result.providerConfigs, hasLength(1)); + expect(result.providerConfigs[0], isA()); + expect(result.providerConfigs[0].providerId, equals('saml.provider1')); + verify(() => clientMock.send(any())).called(1); + }); + + test('returns empty list when no configs exist', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode(jsonEncode({'oauthIdpConfigs': []})), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-list-configs-empty', + ); + final testAuth = Auth(app); + + final result = await testAuth.listProviderConfigs( + AuthProviderConfigFilter.oidc(), + ); + + expect(result.providerConfigs, isEmpty); + expect(result.pageToken, isNull); + verify(() => clientMock.send(any())).called(1); + }); + + test('throws error when backend returns error', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'error': {'code': 500, 'message': 'INTERNAL_ERROR'}, + }), + ), + ), + 500, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-list-configs-error', + ); + final testAuth = Auth(app); + + await expectLater( + testAuth.listProviderConfigs(AuthProviderConfigFilter.oidc()), + throwsA(isA()), + ); + + verify(() => clientMock.send(any())).called(1); + }); + }); + + group('updateProviderConfig', () { + test('throws when provider ID is invalid', () async { + // Provider ID must start with "oidc." or "saml." + await expectLater( + () => auth.updateProviderConfig( + 'unsupported', + OIDCUpdateAuthProviderRequest(displayName: 'Test'), + ), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/invalid-provider-id', + ), + ), + ); + }); + + test('updates OIDC provider config successfully', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'name': 'projects/project_id/oauthIdpConfigs/oidc.provider', + 'displayName': 'Updated OIDC Display Name', + 'enabled': true, + 'clientId': 'UPDATED_CLIENT_ID', + 'issuer': 'https://updated-oidc.com/issuer', + 'clientSecret': 'CLIENT_SECRET', + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-update-oidc-config', + ); + final testAuth = Auth(app); + + final config = await testAuth.updateProviderConfig( + 'oidc.provider', + OIDCUpdateAuthProviderRequest( + displayName: 'Updated OIDC Display Name', + clientId: 'UPDATED_CLIENT_ID', + issuer: 'https://updated-oidc.com/issuer', + ), + ); + + expect(config, isA()); + expect(config.providerId, equals('oidc.provider')); + expect(config.displayName, equals('Updated OIDC Display Name')); + verify(() => clientMock.send(any())).called(1); + }); + + test('updates SAML provider config successfully', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'name': + 'projects/project_id/inboundSamlConfigs/saml.provider', + 'idpConfig': { + 'idpEntityId': 'UPDATED_IDP_ENTITY_ID', + 'ssoUrl': 'https://updated-saml.com/login', + 'idpCertificates': [ + {'x509Certificate': 'UPDATED_CERT'}, + ], + }, + 'spConfig': { + 'spEntityId': 'UPDATED_RP_ENTITY_ID', + 'callbackUri': + 'https://project-id.firebaseapp.com/__/auth/handler', + }, + 'displayName': 'Updated SAML Display Name', + 'enabled': true, + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-update-saml-config', + ); + final testAuth = Auth(app); + + final config = await testAuth.updateProviderConfig( + 'saml.provider', + SAMLUpdateAuthProviderRequest( + displayName: 'Updated SAML Display Name', + idpEntityId: 'UPDATED_IDP_ENTITY_ID', + ssoURL: 'https://updated-saml.com/login', + ), + ); + + expect(config, isA()); + expect(config.providerId, equals('saml.provider')); + expect(config.displayName, equals('Updated SAML Display Name')); + verify(() => clientMock.send(any())).called(1); + }); + + test('throws error when backend returns error for OIDC', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'error': { + 'code': 404, + 'message': 'CONFIGURATION_NOT_FOUND', + }, + }), + ), + ), + 404, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-update-oidc-error', + ); + final testAuth = Auth(app); + + await expectLater( + testAuth.updateProviderConfig( + 'oidc.provider', + OIDCUpdateAuthProviderRequest(displayName: 'Test'), + ), + throwsA(isA()), + ); + + verify(() => clientMock.send(any())).called(1); + }); + + test('throws error when backend returns error for SAML', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'error': { + 'code': 404, + 'message': 'CONFIGURATION_NOT_FOUND', + }, + }), + ), + ), + 404, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-update-saml-error', + ); + final testAuth = Auth(app); + + await expectLater( + testAuth.updateProviderConfig( + 'saml.provider', + SAMLUpdateAuthProviderRequest(displayName: 'Test'), + ), + throwsA(isA()), + ); + + verify(() => clientMock.send(any())).called(1); + }); + }); + + group('updateUser', () { + test('updates user successfully', () async { + const testUid = 'test-uid-123'; + final clientMock = ClientMock(); + var callCount = 0; + when(() => clientMock.send(any())).thenAnswer((_) { + callCount++; + // First call: setAccountInfo (updateExistingAccount) - returns localId + if (callCount == 1) { + return Future.value( + StreamedResponse( + Stream.value(utf8.encode(jsonEncode({'localId': testUid}))), + 200, + headers: {'content-type': 'application/json'}, + ), + ); + } + // Second call: lookup (getAccountInfoByUid) - returns updated user info + return Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'users': [ + { + 'localId': testUid, + 'email': 'updated@example.com', + 'displayName': 'Updated Name', + 'emailVerified': true, + 'disabled': false, + 'createdAt': '1234567890000', + }, + ], + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ); + }); + + final app = createApp(client: clientMock, name: 'test-update-user'); + final testAuth = Auth(app); + + final user = await testAuth.updateUser( + testUid, + UpdateRequest( + email: 'updated@example.com', + displayName: 'Updated Name', + emailVerified: true, + ), + ); + + expect(user.uid, equals(testUid)); + expect(user.email, equals('updated@example.com')); + expect(user.displayName, equals('Updated Name')); + expect(user.emailVerified, isTrue); + verify(() => clientMock.send(any())).called(2); + }); + + test('throws when uid is empty', () async { + await expectLater( + () => auth.updateUser('', UpdateRequest(email: 'test@example.com')), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/invalid-uid', + ), + ), + ); + }); + + test('throws when uid is invalid (too long)', () async { + final invalidUid = 'a' * 129; // UID must be <= 128 characters + await expectLater( + () => auth.updateUser( + invalidUid, + UpdateRequest(email: 'test@example.com'), + ), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/invalid-uid', + ), + ), + ); + }); + + test('throws error when backend returns error', () async { + const testUid = 'test-uid-123'; + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'error': {'code': 404, 'message': 'USER_NOT_FOUND'}, + }), + ), + ), + 404, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-update-user-error', + ); + final testAuth = Auth(app); + + await expectLater( + testAuth.updateUser( + testUid, + UpdateRequest(email: 'test@example.com'), + ), + throwsA(isA()), + ); + + verify(() => clientMock.send(any())).called(1); + }); + }); + + group('verifyIdToken', () { + test('verifies ID token successfully', () async { + final mockTokenVerifier = MockFirebaseTokenVerifier(); + final decodedToken = DecodedIdToken.fromMap({ + 'sub': 'test-uid-123', + 'uid': 'test-uid-123', + 'aud': 'test-project', + 'iss': 'https://securetoken.google.com/test-project', + 'iat': DateTime.now().millisecondsSinceEpoch ~/ 1000, + 'exp': + DateTime.now() + .add(const Duration(hours: 1)) + .millisecondsSinceEpoch ~/ + 1000, + 'auth_time': DateTime.now().millisecondsSinceEpoch ~/ 1000, + 'firebase': { + 'identities': {}, + 'sign_in_provider': 'custom', + }, + }); + + when( + () => mockTokenVerifier.verifyJWT( + any(), + isEmulator: any(named: 'isEmulator'), + ), + ).thenAnswer((_) async => decodedToken); + + // Always mock HTTP client for getUser calls (needed when emulator is enabled or checkRevoked is true) + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'users': [ + { + 'localId': 'test-uid-123', + 'email': 'test@example.com', + 'disabled': false, + 'createdAt': '1234567890000', + }, + ], + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp(name: 'test-verify-id-token', client: clientMock); + final testAuth = Auth.internal(app, idTokenVerifier: mockTokenVerifier); + + final result = await testAuth.verifyIdToken('mock-token'); + + expect(result.uid, equals('test-uid-123')); + expect(result.sub, equals('test-uid-123')); + verify( + () => mockTokenVerifier.verifyJWT( + 'mock-token', + isEmulator: any(named: 'isEmulator'), + ), + ).called(1); + }); + + test('throws when idToken is empty', () async { + final mockTokenVerifier = MockFirebaseTokenVerifier(); + when( + () => mockTokenVerifier.verifyJWT( + any(), + isEmulator: any(named: 'isEmulator'), + ), + ).thenThrow( + FirebaseAuthAdminException( + AuthClientErrorCode.invalidArgument, + 'Firebase ID token has invalid format.', + ), + ); + + final app = createApp(name: 'test-verify-id-token-empty'); + final testAuth = Auth.internal(app, idTokenVerifier: mockTokenVerifier); + + await expectLater( + () => testAuth.verifyIdToken(''), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/argument-error', + ), + ), + ); + }); + + test('throws when idToken is invalid', () async { + final mockTokenVerifier = MockFirebaseTokenVerifier(); + when( + () => mockTokenVerifier.verifyJWT( + any(), + isEmulator: any(named: 'isEmulator'), + ), + ).thenThrow( + FirebaseAuthAdminException( + AuthClientErrorCode.invalidArgument, + 'Decoding Firebase ID token failed.', + ), + ); + + final app = createApp(name: 'test-verify-id-token-invalid'); + final testAuth = Auth.internal(app, idTokenVerifier: mockTokenVerifier); + + await expectLater( + () => testAuth.verifyIdToken('invalid-token'), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/argument-error', + ), + ), + ); + }); + + test('throws when checkRevoked is true and user is disabled', () async { + final mockTokenVerifier = MockFirebaseTokenVerifier(); + final decodedToken = DecodedIdToken.fromMap({ + 'sub': 'test-uid-123', + 'uid': 'test-uid-123', + 'aud': 'test-project', + 'iss': 'https://securetoken.google.com/test-project', + 'iat': DateTime.now().millisecondsSinceEpoch ~/ 1000, + 'exp': + DateTime.now() + .add(const Duration(hours: 1)) + .millisecondsSinceEpoch ~/ + 1000, + 'auth_time': DateTime.now().millisecondsSinceEpoch ~/ 1000, + 'firebase': { + 'identities': {}, + 'sign_in_provider': 'custom', + }, + }); + + when( + () => mockTokenVerifier.verifyJWT( + any(), + isEmulator: any(named: 'isEmulator'), + ), + ).thenAnswer((_) async => decodedToken); + + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'users': [ + { + 'localId': 'test-uid-123', + 'email': 'test@example.com', + 'disabled': true, + 'createdAt': '1234567890000', + }, + ], + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-verify-id-token-disabled', + ); + final testAuth = Auth.internal(app, idTokenVerifier: mockTokenVerifier); + + await expectLater( + () => testAuth.verifyIdToken('mock-token', checkRevoked: true), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/user-disabled', + ), + ), + ); + }); + + test('throws when checkRevoked is true and token is revoked', () async { + final mockTokenVerifier = MockFirebaseTokenVerifier(); + // Token with auth_time before validSince + final authTime = DateTime.now().subtract(const Duration(hours: 2)); + final decodedToken = DecodedIdToken.fromMap({ + 'sub': 'test-uid-123', + 'uid': 'test-uid-123', + 'aud': 'test-project', + 'iss': 'https://securetoken.google.com/test-project', + 'iat': DateTime.now().millisecondsSinceEpoch ~/ 1000, + 'exp': + DateTime.now() + .add(const Duration(hours: 1)) + .millisecondsSinceEpoch ~/ + 1000, + 'auth_time': authTime.millisecondsSinceEpoch ~/ 1000, + 'firebase': { + 'identities': {}, + 'sign_in_provider': 'custom', + }, + }); + + when( + () => mockTokenVerifier.verifyJWT( + any(), + isEmulator: any(named: 'isEmulator'), + ), + ).thenAnswer((_) async => decodedToken); + + final clientMock = ClientMock(); + // validSince is after auth_time, so token is revoked + final validSince = DateTime.now().subtract(const Duration(hours: 1)); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'users': [ + { + 'localId': 'test-uid-123', + 'email': 'test@example.com', + 'disabled': false, + 'validSince': + (validSince.millisecondsSinceEpoch ~/ 1000) + .toString(), + 'createdAt': '1234567890000', + }, + ], + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-verify-id-token-revoked', + ); + final testAuth = Auth.internal(app, idTokenVerifier: mockTokenVerifier); + + await expectLater( + () => testAuth.verifyIdToken('mock-token', checkRevoked: true), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/id-token-revoked', + ), + ), + ); + }); + + test( + 'succeeds when checkRevoked is true and token is not revoked', + () async { + final mockTokenVerifier = MockFirebaseTokenVerifier(); + // Token with auth_time after validSince + final authTime = DateTime.now().subtract(const Duration(minutes: 30)); + final decodedToken = DecodedIdToken.fromMap({ + 'sub': 'test-uid-123', + 'uid': 'test-uid-123', + 'aud': 'test-project', + 'iss': 'https://securetoken.google.com/test-project', + 'iat': DateTime.now().millisecondsSinceEpoch ~/ 1000, + 'exp': + DateTime.now() + .add(const Duration(hours: 1)) + .millisecondsSinceEpoch ~/ + 1000, + 'auth_time': authTime.millisecondsSinceEpoch ~/ 1000, + 'firebase': { + 'identities': {}, + 'sign_in_provider': 'custom', + }, + }); + + when( + () => mockTokenVerifier.verifyJWT( + any(), + isEmulator: any(named: 'isEmulator'), + ), + ).thenAnswer((_) async => decodedToken); + + final clientMock = ClientMock(); + // validSince is before auth_time, so token is not revoked + final validSince = DateTime.now().subtract(const Duration(hours: 1)); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'users': [ + { + 'localId': 'test-uid-123', + 'email': 'test@example.com', + 'disabled': false, + 'validSince': + (validSince.millisecondsSinceEpoch ~/ 1000) + .toString(), + 'createdAt': '1234567890000', + }, + ], + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-verify-id-token-not-revoked', + ); + final testAuth = Auth.internal( + app, + idTokenVerifier: mockTokenVerifier, + ); + + final result = await testAuth.verifyIdToken( + 'mock-token', + checkRevoked: true, + ); + + expect(result.uid, equals('test-uid-123')); + verify( + () => mockTokenVerifier.verifyJWT( + 'mock-token', + isEmulator: any(named: 'isEmulator'), + ), + ).called(1); + }, + ); + }); + + group('createSessionCookie', () { + test('creates session cookie successfully', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({'sessionCookie': 'session-cookie-string'}), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp(client: clientMock, name: 'test-session-cookie'); + final testAuth = Auth(app); + + final sessionCookie = await testAuth.createSessionCookie( + 'id-token', + const SessionCookieOptions( + expiresIn: 3600000, + ), // 1 hour in milliseconds + ); + + expect(sessionCookie, equals('session-cookie-string')); + verify(() => clientMock.send(any())).called(1); + }); + + test('throws when idToken is empty', () async { + expect( + () => auth.createSessionCookie( + '', + const SessionCookieOptions(expiresIn: 3600000), + ), + throwsA(isA()), + ); + }); + + test('validates expiresIn duration - too short', () async { + // expiresIn must be between 5 minutes (300000 ms) and 2 weeks (1209600000 ms) + expect( + () => auth.createSessionCookie( + 'id-token', + const SessionCookieOptions( + expiresIn: 60000, + ), // 1 minute - too short + ), + throwsA(isA()), + ); + }); + + test('validates expiresIn duration - too long', () async { + // expiresIn must not exceed 2 weeks (1209600000 ms) + expect( + () => auth.createSessionCookie( + 'id-token', + const SessionCookieOptions( + expiresIn: 15 * 24 * 60 * 60 * 1000, // 15 days - too long + ), + ), + throwsA(isA()), + ); + }); + + test('validates expiresIn duration - minimum allowed', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({'sessionCookie': 'session-cookie-string'}), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp(client: clientMock, name: 'test-min-duration'); + final testAuth = Auth(app); + + // 5 minutes (300000 ms) is the minimum allowed + final sessionCookie = await testAuth.createSessionCookie( + 'id-token', + const SessionCookieOptions(expiresIn: 5 * 60 * 1000), // 5 minutes + ); + + expect(sessionCookie, equals('session-cookie-string')); + }); + + test('validates expiresIn duration - maximum allowed', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({'sessionCookie': 'session-cookie-string'}), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp(client: clientMock, name: 'test-max-duration'); + final testAuth = Auth(app); + + // 2 weeks (1209600000 ms) is the maximum allowed + final sessionCookie = await testAuth.createSessionCookie( + 'id-token', + const SessionCookieOptions( + expiresIn: 14 * 24 * 60 * 60 * 1000, // 2 weeks + ), + ); + + expect(sessionCookie, equals('session-cookie-string')); + }); + + test('handles backend error', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'error': {'code': 400, 'message': 'INVALID_ID_TOKEN'}, + }), + ), + ), + 400, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp(client: clientMock, name: 'test-backend-error'); + final testAuth = Auth(app); + + await expectLater( + () => testAuth.createSessionCookie( + 'invalid-id-token', + const SessionCookieOptions(expiresIn: 3600000), + ), + throwsA(isA()), + ); + }); + }); + + group('createUser', () { + test('creates user successfully', () async { + const testUid = 'test-uid-123'; + final clientMock = ClientMock(); + var callCount = 0; + when(() => clientMock.send(any())).thenAnswer((_) { + callCount++; + // First call: signUp (createNewAccount) - returns localId + if (callCount == 1) { + return Future.value( + StreamedResponse( + Stream.value(utf8.encode(jsonEncode({'localId': testUid}))), + 200, + headers: {'content-type': 'application/json'}, + ), + ); + } + // Second call: lookup (getAccountInfoByUid) - returns user info + return Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'users': [ + { + 'localId': testUid, + 'email': 'test@example.com', + 'displayName': 'Test User', + 'emailVerified': false, + 'disabled': false, + 'createdAt': '1234567890000', + }, + ], + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ); + }); + + final app = createApp(client: clientMock, name: 'test-create-user'); + final testAuth = Auth(app); + + final user = await testAuth.createUser( + CreateRequest(email: 'test@example.com', displayName: 'Test User'), + ); + + expect(user.uid, equals(testUid)); + expect(user.email, equals('test@example.com')); + expect(user.displayName, equals('Test User')); + expect(user.emailVerified, isFalse); + expect(user.disabled, isFalse); + verify(() => clientMock.send(any())).called(2); + }); + + test('throws error when createNewAccount fails', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'error': {'code': 400, 'message': 'EMAIL_ALREADY_EXISTS'}, + }), + ), + ), + 400, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-create-user-error', + ); + final testAuth = Auth(app); + + await expectLater( + testAuth.createUser(CreateRequest(email: 'existing@example.com')), + throwsA(isA()), + ); + + verify(() => clientMock.send(any())).called(1); + }); + + test('throws internal error when getUser returns user not found', () async { + const testUid = 'test-uid-123'; + final clientMock = ClientMock(); + var callCount = 0; + when(() => clientMock.send(any())).thenAnswer((_) { + callCount++; + // First call: signUp (createNewAccount) - returns localId + if (callCount == 1) { + return Future.value( + StreamedResponse( + Stream.value(utf8.encode(jsonEncode({'localId': testUid}))), + 200, + headers: {'content-type': 'application/json'}, + ), + ); + } + // Second call: lookup (getAccountInfoByUid) - returns empty users (user not found) + return Future.value( + StreamedResponse( + Stream.value(utf8.encode(jsonEncode({'users': []}))), + 200, + headers: {'content-type': 'application/json'}, + ), + ); + }); + + final app = createApp( + client: clientMock, + name: 'test-create-user-not-found', + ); + final testAuth = Auth(app); + + await expectLater( + testAuth.createUser(CreateRequest(email: 'test@example.com')), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/internal-error', + ), + ), + ); + + verify(() => clientMock.send(any())).called(2); + }); + + test( + 'propagates error when getUser fails with non-user-not-found error', + () async { + const testUid = 'test-uid-123'; + final clientMock = ClientMock(); + var callCount = 0; + when(() => clientMock.send(any())).thenAnswer((_) { + callCount++; + // First call: signUp (createNewAccount) - returns localId + if (callCount == 1) { + return Future.value( + StreamedResponse( + Stream.value(utf8.encode(jsonEncode({'localId': testUid}))), + 200, + headers: {'content-type': 'application/json'}, + ), + ); + } + // Second call: lookup (getAccountInfoByUid) - returns error + return Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'error': {'code': 500, 'message': 'INTERNAL_ERROR'}, + }), + ), + ), + 500, + headers: {'content-type': 'application/json'}, + ), + ); + }); + + final app = createApp( + client: clientMock, + name: 'test-create-user-get-error', + ); + final testAuth = Auth(app); + + await expectLater( + testAuth.createUser(CreateRequest(email: 'test@example.com')), + throwsA(isA()), + ); + + verify(() => clientMock.send(any())).called(2); + }, + ); + }); + + group('deleteProviderConfig', () { + test('throws when provider ID is invalid', () async { + // Provider ID must start with "oidc." or "saml." + await expectLater( + () => auth.deleteProviderConfig('unsupported'), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/invalid-provider-id', + ), + ), + ); + }); + + test('deletes OIDC provider config successfully', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value(utf8.encode(jsonEncode({}))), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp(client: clientMock, name: 'test-delete-oidc'); + final testAuth = Auth(app); + + await testAuth.deleteProviderConfig('oidc.provider'); + + verify(() => clientMock.send(any())).called(1); + }); + + test('deletes SAML provider config successfully', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value(utf8.encode(jsonEncode({}))), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp(client: clientMock, name: 'test-delete-saml'); + final testAuth = Auth(app); + + await testAuth.deleteProviderConfig('saml.provider'); + + verify(() => clientMock.send(any())).called(1); + }); + + test('throws error when backend returns error for OIDC', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'error': { + 'code': 404, + 'message': 'CONFIGURATION_NOT_FOUND', + }, + }), + ), + ), + 404, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-delete-oidc-error', + ); + final testAuth = Auth(app); + + await expectLater( + testAuth.deleteProviderConfig('oidc.provider'), + throwsA(isA()), + ); + + verify(() => clientMock.send(any())).called(1); + }); + + test('throws error when backend returns error for SAML', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'error': { + 'code': 404, + 'message': 'CONFIGURATION_NOT_FOUND', + }, + }), + ), + ), + 404, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-delete-saml-error', + ); + final testAuth = Auth(app); + + await expectLater( + testAuth.deleteProviderConfig('saml.provider'), + throwsA(isA()), + ); + + verify(() => clientMock.send(any())).called(1); + }); + }); + + group('getProviderConfig', () { + test('throws when provider ID is invalid', () async { + // Provider ID must start with "oidc." or "saml." + await expectLater( + () => auth.getProviderConfig('unsupported'), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/invalid-provider-id', + ), + ), + ); + }); + + test('gets OIDC provider config successfully', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'name': 'projects/project_id/oauthIdpConfigs/oidc.provider', + 'displayName': 'OIDC_DISPLAY_NAME', + 'enabled': true, + 'clientId': 'CLIENT_ID', + 'issuer': 'https://oidc.com/issuer', + 'clientSecret': 'CLIENT_SECRET', + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp(client: clientMock, name: 'test-get-oidc'); + final testAuth = Auth(app); + + final config = await testAuth.getProviderConfig('oidc.provider'); + + expect(config, isA()); + expect(config.providerId, equals('oidc.provider')); + expect(config.displayName, equals('OIDC_DISPLAY_NAME')); + expect(config.enabled, isTrue); + verify(() => clientMock.send(any())).called(1); + }); + + test('gets SAML provider config successfully', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'name': + 'projects/project_id/inboundSamlConfigs/saml.provider', + 'idpConfig': { + 'idpEntityId': 'IDP_ENTITY_ID', + 'ssoUrl': 'https://example.com/login', + 'idpCertificates': [ + {'x509Certificate': 'CERT1'}, + {'x509Certificate': 'CERT2'}, + ], + }, + 'spConfig': { + 'spEntityId': 'RP_ENTITY_ID', + 'callbackUri': + 'https://project-id.firebaseapp.com/__/auth/handler', + }, + 'displayName': 'SAML_DISPLAY_NAME', + 'enabled': true, + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp(client: clientMock, name: 'test-get-saml'); + final testAuth = Auth(app); + + final config = await testAuth.getProviderConfig('saml.provider'); + + expect(config, isA()); + expect(config.providerId, equals('saml.provider')); + expect(config.displayName, equals('SAML_DISPLAY_NAME')); + expect(config.enabled, isTrue); + verify(() => clientMock.send(any())).called(1); + }); + + test('throws error when backend returns error for OIDC', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'error': { + 'code': 404, + 'message': 'CONFIGURATION_NOT_FOUND', + }, + }), + ), + ), + 404, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp(client: clientMock, name: 'test-get-oidc-error'); + final testAuth = Auth(app); + + await expectLater( + testAuth.getProviderConfig('oidc.provider'), + throwsA(isA()), + ); + + verify(() => clientMock.send(any())).called(1); + }); + + test('throws error when backend returns error for SAML', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'error': { + 'code': 404, + 'message': 'CONFIGURATION_NOT_FOUND', + }, + }), + ), + ), + 404, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp(client: clientMock, name: 'test-get-saml-error'); + final testAuth = Auth(app); + + await expectLater( + testAuth.getProviderConfig('saml.provider'), + throwsA(isA()), + ); + + verify(() => clientMock.send(any())).called(1); + }); + }); + + group('createProviderConfig', () { + test('throws when provider ID is invalid', () async { + // Provider ID must start with "oidc." or "saml." + final invalidConfig = OIDCAuthProviderConfig( + providerId: 'unsupported', + displayName: 'OIDC provider', + enabled: true, + clientId: 'CLIENT_ID', + issuer: 'https://oidc.com/issuer', + ); + + await expectLater( + auth.createProviderConfig(invalidConfig), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/invalid-provider-id', + ), + ), + ); + }); + + test('creates OIDC provider config successfully', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'name': 'projects/project_id/oauthIdpConfigs/oidc.provider', + 'displayName': 'OIDC_DISPLAY_NAME', + 'enabled': true, + 'clientId': 'CLIENT_ID', + 'issuer': 'https://oidc.com/issuer', + 'clientSecret': 'CLIENT_SECRET', + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp(client: clientMock, name: 'test-create-oidc'); + final testAuth = Auth(app); + + final config = await testAuth.createProviderConfig( + OIDCAuthProviderConfig( + providerId: 'oidc.provider', + displayName: 'OIDC_DISPLAY_NAME', + enabled: true, + clientId: 'CLIENT_ID', + issuer: 'https://oidc.com/issuer', + clientSecret: 'CLIENT_SECRET', + ), + ); + + expect(config, isA()); + expect(config.providerId, equals('oidc.provider')); + expect(config.displayName, equals('OIDC_DISPLAY_NAME')); + expect(config.enabled, isTrue); + verify(() => clientMock.send(any())).called(1); + }); + + test('creates SAML provider config successfully', () async { + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'name': + 'projects/project_id/inboundSamlConfigs/saml.provider', + 'idpConfig': { + 'idpEntityId': 'IDP_ENTITY_ID', + 'ssoUrl': 'https://example.com/login', + 'idpCertificates': [ + {'x509Certificate': 'CERT1'}, + {'x509Certificate': 'CERT2'}, + ], + }, + 'spConfig': { + 'spEntityId': 'RP_ENTITY_ID', + 'callbackUri': + 'https://project-id.firebaseapp.com/__/auth/handler', + }, + 'displayName': 'SAML_DISPLAY_NAME', + 'enabled': true, + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp(client: clientMock, name: 'test-create-saml'); + final testAuth = Auth(app); + + final config = await testAuth.createProviderConfig( + SAMLAuthProviderConfig( + providerId: 'saml.provider', + displayName: 'SAML_DISPLAY_NAME', + enabled: true, + idpEntityId: 'IDP_ENTITY_ID', + ssoURL: 'https://example.com/login', + x509Certificates: ['CERT1', 'CERT2'], + rpEntityId: 'RP_ENTITY_ID', + callbackURL: 'https://project-id.firebaseapp.com/__/auth/handler', + ), + ); + + expect(config, isA()); + expect(config.providerId, equals('saml.provider')); + expect(config.displayName, equals('SAML_DISPLAY_NAME')); + expect(config.enabled, isTrue); + verify(() => clientMock.send(any())).called(1); + }); + }); + + group('verifySessionCookie', () { + test('verifies session cookie successfully', () async { + final mockSessionCookieVerifier = MockFirebaseTokenVerifier(); + final decodedToken = DecodedIdToken.fromMap({ + 'sub': 'test-uid-123', + 'uid': 'test-uid-123', + 'aud': 'test-project', + 'iss': 'https://session.firebase.google.com/test-project', + 'iat': DateTime.now().millisecondsSinceEpoch ~/ 1000, + 'exp': + DateTime.now() + .add(const Duration(hours: 1)) + .millisecondsSinceEpoch ~/ + 1000, + 'auth_time': DateTime.now().millisecondsSinceEpoch ~/ 1000, + 'firebase': { + 'identities': {}, + 'sign_in_provider': 'custom', + }, + }); + + when( + () => mockSessionCookieVerifier.verifyJWT( + any(), + isEmulator: any(named: 'isEmulator'), + ), + ).thenAnswer((_) async => decodedToken); + + // Always mock HTTP client for getUser calls (needed when emulator is enabled or checkRevoked is true) + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'users': [ + { + 'localId': 'test-uid-123', + 'email': 'test@example.com', + 'disabled': false, + 'createdAt': '1234567890000', + }, + ], + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + name: 'test-verify-session-cookie', + client: clientMock, + ); + final testAuth = Auth.internal( + app, + sessionCookieVerifier: mockSessionCookieVerifier, + ); + + final result = await testAuth.verifySessionCookie( + 'mock-session-cookie', + ); + + expect(result.uid, equals('test-uid-123')); + expect(result.sub, equals('test-uid-123')); + verify( + () => mockSessionCookieVerifier.verifyJWT( + 'mock-session-cookie', + isEmulator: any(named: 'isEmulator'), + ), + ).called(1); + }); + + test('throws when sessionCookie is empty', () async { + final mockSessionCookieVerifier = MockFirebaseTokenVerifier(); + when( + () => mockSessionCookieVerifier.verifyJWT( + any(), + isEmulator: any(named: 'isEmulator'), + ), + ).thenThrow( + FirebaseAuthAdminException( + AuthClientErrorCode.invalidArgument, + 'Firebase session cookie has invalid format.', + ), + ); + + final app = createApp(name: 'test-verify-session-cookie-empty'); + final testAuth = Auth.internal( + app, + sessionCookieVerifier: mockSessionCookieVerifier, + ); + + await expectLater( + () => testAuth.verifySessionCookie(''), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/argument-error', + ), + ), + ); + }); + + test('throws when sessionCookie is invalid', () async { + final mockSessionCookieVerifier = MockFirebaseTokenVerifier(); + when( + () => mockSessionCookieVerifier.verifyJWT( + any(), + isEmulator: any(named: 'isEmulator'), + ), + ).thenThrow( + FirebaseAuthAdminException( + AuthClientErrorCode.invalidArgument, + 'Decoding Firebase session cookie failed.', + ), + ); + + final app = createApp(name: 'test-verify-session-cookie-invalid'); + final testAuth = Auth.internal( + app, + sessionCookieVerifier: mockSessionCookieVerifier, + ); + + await expectLater( + () => testAuth.verifySessionCookie('invalid-cookie'), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/argument-error', + ), + ), + ); + }); + + test('throws when checkRevoked is true and user is disabled', () async { + final mockSessionCookieVerifier = MockFirebaseTokenVerifier(); + final decodedToken = DecodedIdToken.fromMap({ + 'sub': 'test-uid-123', + 'uid': 'test-uid-123', + 'aud': 'test-project', + 'iss': 'https://session.firebase.google.com/test-project', + 'iat': DateTime.now().millisecondsSinceEpoch ~/ 1000, + 'exp': + DateTime.now() + .add(const Duration(hours: 1)) + .millisecondsSinceEpoch ~/ + 1000, + 'auth_time': DateTime.now().millisecondsSinceEpoch ~/ 1000, + 'firebase': { + 'identities': {}, + 'sign_in_provider': 'custom', + }, + }); + + when( + () => mockSessionCookieVerifier.verifyJWT( + any(), + isEmulator: any(named: 'isEmulator'), + ), + ).thenAnswer((_) async => decodedToken); + + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'users': [ + { + 'localId': 'test-uid-123', + 'email': 'test@example.com', + 'disabled': true, + 'createdAt': '1234567890000', + }, + ], + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-verify-session-cookie-disabled', + ); + final testAuth = Auth.internal( + app, + sessionCookieVerifier: mockSessionCookieVerifier, + ); + + await expectLater( + () => testAuth.verifySessionCookie('mock-cookie', checkRevoked: true), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/user-disabled', + ), + ), + ); + }); + + test('throws when checkRevoked is true and cookie is revoked', () async { + final mockSessionCookieVerifier = MockFirebaseTokenVerifier(); + // Cookie with auth_time before validSince + final authTime = DateTime.now().subtract(const Duration(hours: 2)); + final decodedToken = DecodedIdToken.fromMap({ + 'sub': 'test-uid-123', + 'uid': 'test-uid-123', + 'aud': 'test-project', + 'iss': 'https://session.firebase.google.com/test-project', + 'iat': DateTime.now().millisecondsSinceEpoch ~/ 1000, + 'exp': + DateTime.now() + .add(const Duration(hours: 1)) + .millisecondsSinceEpoch ~/ + 1000, + 'auth_time': authTime.millisecondsSinceEpoch ~/ 1000, + 'firebase': { + 'identities': {}, + 'sign_in_provider': 'custom', + }, + }); + + when( + () => mockSessionCookieVerifier.verifyJWT( + any(), + isEmulator: any(named: 'isEmulator'), + ), + ).thenAnswer((_) async => decodedToken); + + final clientMock = ClientMock(); + // validSince is after auth_time, so cookie is revoked + final validSince = DateTime.now().subtract(const Duration(hours: 1)); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'users': [ + { + 'localId': 'test-uid-123', + 'email': 'test@example.com', + 'disabled': false, + 'validSince': + (validSince.millisecondsSinceEpoch ~/ 1000) + .toString(), + 'createdAt': '1234567890000', + }, + ], + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-verify-session-cookie-revoked', + ); + final testAuth = Auth.internal( + app, + sessionCookieVerifier: mockSessionCookieVerifier, + ); + + await expectLater( + () => testAuth.verifySessionCookie('mock-cookie', checkRevoked: true), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/session-cookie-revoked', + ), + ), + ); + }); + + test( + 'succeeds when checkRevoked is true and cookie is not revoked', + () async { + final mockSessionCookieVerifier = MockFirebaseTokenVerifier(); + // Cookie with auth_time after validSince + final authTime = DateTime.now().subtract(const Duration(minutes: 30)); + final decodedToken = DecodedIdToken.fromMap({ + 'sub': 'test-uid-123', + 'uid': 'test-uid-123', + 'aud': 'test-project', + 'iss': 'https://session.firebase.google.com/test-project', + 'iat': DateTime.now().millisecondsSinceEpoch ~/ 1000, + 'exp': + DateTime.now() + .add(const Duration(hours: 1)) + .millisecondsSinceEpoch ~/ + 1000, + 'auth_time': authTime.millisecondsSinceEpoch ~/ 1000, + 'firebase': { + 'identities': {}, + 'sign_in_provider': 'custom', + }, + }); + + when( + () => mockSessionCookieVerifier.verifyJWT( + any(), + isEmulator: any(named: 'isEmulator'), + ), + ).thenAnswer((_) async => decodedToken); + + final clientMock = ClientMock(); + // validSince is before auth_time, so cookie is not revoked + final validSince = DateTime.now().subtract(const Duration(hours: 1)); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'users': [ + { + 'localId': 'test-uid-123', + 'email': 'test@example.com', + 'disabled': false, + 'validSince': + (validSince.millisecondsSinceEpoch ~/ 1000) + .toString(), + 'createdAt': '1234567890000', + }, + ], + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-verify-session-cookie-not-revoked', + ); + final testAuth = Auth.internal( + app, + sessionCookieVerifier: mockSessionCookieVerifier, + ); + + final result = await testAuth.verifySessionCookie( + 'mock-cookie', + checkRevoked: true, + ); + + expect(result.uid, equals('test-uid-123')); + verify( + () => mockSessionCookieVerifier.verifyJWT( + 'mock-cookie', + isEmulator: any(named: 'isEmulator'), + ), + ).called(1); + }, + ); + }); }); } diff --git a/packages/dart_firebase_admin/test/auth/integration_test.dart b/packages/dart_firebase_admin/test/auth/integration_test.dart index 397540d9..83fed8a6 100644 --- a/packages/dart_firebase_admin/test/auth/integration_test.dart +++ b/packages/dart_firebase_admin/test/auth/integration_test.dart @@ -1,7 +1,21 @@ +// Firebase Auth Integration Tests +// +// SAFETY: These tests require Firebase Auth Emulator by default to prevent +// accidental writes to production. +// +// All tests use the global `auth` instance from main setUp() which automatically +// requires FIREBASE_AUTH_EMULATOR_HOST to be set. This is safe to run without +// production credentials. +// +// For production-only tests (Session Cookies, getUsers, Provider Configs, etc.), +// see test/auth/auth_integration_prod_test.dart +// +// To run these tests: +// FIREBASE_AUTH_EMULATOR_HOST=localhost:9099 dart test test/auth/integration_test.dart + import 'dart:convert'; import 'package:dart_firebase_admin/auth.dart'; -import 'package:dart_firebase_admin/src/app.dart'; import 'package:http/http.dart'; import 'package:mocktail/mocktail.dart'; import 'package:test/test.dart'; @@ -9,6 +23,7 @@ import 'package:uuid/uuid.dart'; import '../google_cloud_firestore/util/helpers.dart'; import '../mock.dart'; +import 'util/helpers.dart'; const _uid = Uuid(); @@ -16,8 +31,9 @@ void main() { late Auth auth; setUp(() { - final sdk = createApp(tearDown: () => cleanup(auth)); - auth = Auth(sdk); + // By default, require emulator to prevent accidental production writes + // Production-only tests should override this in their own setUp + auth = createAuthForTest(); }); setUpAll(registerFallbacks); @@ -476,15 +492,96 @@ void main() { }); }); }); -} -Future cleanup(Auth auth) async { - if (!Environment.isAuthEmulatorEnabled()) { - throw Exception('Cannot cleanup non-emulator app'); - } + group('deleteUser', () { + test('deletes user and verifies deletion', () async { + final user = await auth.createUser(CreateRequest(uid: _uid.v4())); + + await auth.deleteUser(user.uid); + + await expectLater( + () => auth.getUser(user.uid), + throwsA(isA()), + ); + }); + }); + + group('deleteUsers', () { + test('deletes multiple users successfully', () async { + final user1 = await auth.createUser(CreateRequest(uid: _uid.v4())); + final user2 = await auth.createUser(CreateRequest(uid: _uid.v4())); + final user3 = await auth.createUser(CreateRequest(uid: _uid.v4())); + + final result = await auth.deleteUsers([user1.uid, user2.uid, user3.uid]); + + expect(result.successCount, equals(3)); + expect(result.failureCount, equals(0)); + expect(result.errors, isEmpty); + }); + + test('reports errors for non-existent users', () async { + final user1 = await auth.createUser(CreateRequest(uid: _uid.v4())); + + final result = await auth.deleteUsers([ + user1.uid, + 'non-existent-uid-1', + 'non-existent-uid-2', + ]); + + // Emulator behavior may differ - it might succeed for non-existent users + expect(result.successCount, greaterThanOrEqualTo(1)); + expect(result.successCount + result.failureCount, equals(3)); + }); + }); + + group('listUsers', () { + test('lists all users', () async { + // Create some test users + await auth.createUser(CreateRequest(uid: _uid.v4())); + await auth.createUser(CreateRequest(uid: _uid.v4())); + await auth.createUser(CreateRequest(uid: _uid.v4())); + + final result = await auth.listUsers(); + + expect(result.users, isNotEmpty); + expect(result.users.length, greaterThanOrEqualTo(3)); + expect(result.users, everyElement(isA())); + }); + + test('supports pagination with maxResults', () async { + // Create several users + for (var i = 0; i < 5; i++) { + await auth.createUser(CreateRequest(uid: _uid.v4())); + } + + final firstPage = await auth.listUsers(maxResults: 2); + + expect(firstPage.users.length, equals(2)); + if (firstPage.pageToken != null) { + expect(firstPage.pageToken, isNotEmpty); + } + }); + + test('supports pagination with pageToken', () async { + // Create several users + for (var i = 0; i < 5; i++) { + await auth.createUser(CreateRequest(uid: _uid.v4())); + } + + final firstPage = await auth.listUsers(maxResults: 2); + + if (firstPage.pageToken != null) { + final secondPage = await auth.listUsers( + maxResults: 2, + pageToken: firstPage.pageToken, + ); - final users = await auth.listUsers(); - await Future.wait([ - for (final user in users.users) auth.deleteUser(user.uid), - ]); + expect(secondPage.users.length, greaterThan(0)); + expect( + secondPage.users.first.uid, + isNot(equals(firstPage.users.first.uid)), + ); + } + }); + }); } diff --git a/packages/dart_firebase_admin/test/auth/project_config_integration_prod_test.dart b/packages/dart_firebase_admin/test/auth/project_config_integration_prod_test.dart new file mode 100644 index 00000000..f77c933c --- /dev/null +++ b/packages/dart_firebase_admin/test/auth/project_config_integration_prod_test.dart @@ -0,0 +1,479 @@ +// Firebase ProjectConfig Integration Tests - Production Only +// +// These tests require production Firebase (GOOGLE_APPLICATION_CREDENTIALS) +// because they test features not available in the emulator: +// - Multi-factor authentication (requires GCIP) +// - TOTP provider configuration (requires GCIP) +// - reCAPTCHA Enterprise configuration +// +// **IMPORTANT:** These tests use runZoned with zoneValues to temporarily +// disable the emulator environment variable. This allows them to run in the +// coverage script (which has emulator vars set) by connecting to production +// only for these specific tests. +// +// Run standalone with: +// GOOGLE_APPLICATION_CREDENTIALS=service-account-key.json dart test test/auth/project_config_integration_prod_test.dart + +import 'dart:async'; +import 'dart:io'; + +import 'package:dart_firebase_admin/auth.dart'; +import 'package:dart_firebase_admin/src/app.dart'; +import 'package:test/test.dart'; + +import '../google_cloud_firestore/util/helpers.dart'; + +void main() { + ProjectConfig? originalConfig; + + // Save original config before running update tests + setUpAll(() async { + if (!hasGoogleEnv) return; + + // Remove emulator env var from the zone environment + final prodEnv = Map.from(Platform.environment); + prodEnv.remove(Environment.firebaseAuthEmulatorHost); + + await runZoned(() async { + final appName = 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final testAuth = Auth(app); + + try { + originalConfig = await testAuth.projectConfigManager.getProjectConfig(); + // ignore: avoid_print + print('Original config saved for restoration after tests'); + } catch (e) { + // ignore: avoid_print + print('Warning: Could not save original config: $e'); + } finally { + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv}); + }); + + // Restore original config after all tests complete + tearDownAll(() async { + if (!hasGoogleEnv || originalConfig == null) return; + + // Remove emulator env var from the zone environment + final prodEnv = Map.from(Platform.environment); + prodEnv.remove(Environment.firebaseAuthEmulatorHost); + + await runZoned(() async { + final appName = 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final testAuth = Auth(app); + + try { + await testAuth.projectConfigManager.updateProjectConfig( + UpdateProjectConfigRequest( + smsRegionConfig: originalConfig!.smsRegionConfig, + multiFactorConfig: originalConfig!.multiFactorConfig, + recaptchaConfig: originalConfig!.recaptchaConfig, + passwordPolicyConfig: originalConfig!.passwordPolicyConfig, + emailPrivacyConfig: originalConfig!.emailPrivacyConfig, + mobileLinksConfig: originalConfig!.mobileLinksConfig, + ), + ); + // ignore: avoid_print + print('Original config restored successfully'); + } catch (e) { + // ignore: avoid_print + print('Warning: Could not restore original config: $e'); + } finally { + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv}); + }); + + group('ProjectConfigManager (Production)', () { + group('updateProjectConfig - MFA', () { + test( + 'updates multi-factor authentication configuration', + () { + // Remove emulator env var from the zone environment + final prodEnv = Map.from(Platform.environment); + prodEnv.remove(Environment.firebaseAuthEmulatorHost); + + return runZoned(() async { + final appName = + 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final testAuth = Auth(app); + final projectConfigManager = testAuth.projectConfigManager; + + try { + final updatedConfig = await projectConfigManager + .updateProjectConfig( + UpdateProjectConfigRequest( + multiFactorConfig: MultiFactorConfig( + state: MultiFactorConfigState.enabled, + factorIds: ['phone'], + ), + ), + ); + + expect(updatedConfig, isA()); + + if (updatedConfig.multiFactorConfig != null) { + expect( + updatedConfig.multiFactorConfig!.state, + equals(MultiFactorConfigState.enabled), + ); + } + } finally { + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv}); + }, + skip: hasGoogleEnv + ? false + : 'Requires GCIP (Google Cloud Identity Platform)', + ); + + test( + 'updates multi-factor authentication with TOTP provider config', + () { + // Remove emulator env var from the zone environment + final prodEnv = Map.from(Platform.environment); + prodEnv.remove(Environment.firebaseAuthEmulatorHost); + + return runZoned(() async { + final appName = + 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final testAuth = Auth(app); + final projectConfigManager = testAuth.projectConfigManager; + + try { + final updatedConfig = await projectConfigManager + .updateProjectConfig( + UpdateProjectConfigRequest( + multiFactorConfig: MultiFactorConfig( + state: MultiFactorConfigState.enabled, + providerConfigs: [ + MultiFactorProviderConfig( + state: MultiFactorConfigState.enabled, + totpProviderConfig: TotpMultiFactorProviderConfig(), + ), + ], + ), + ), + ); + + expect(updatedConfig, isA()); + + if (updatedConfig.multiFactorConfig != null) { + expect( + updatedConfig.multiFactorConfig!.state, + equals(MultiFactorConfigState.enabled), + ); + expect( + updatedConfig.multiFactorConfig!.providerConfigs, + isNotNull, + ); + if (updatedConfig.multiFactorConfig!.providerConfigs != null) { + expect( + updatedConfig.multiFactorConfig!.providerConfigs!.length, + equals(1), + ); + expect( + updatedConfig.multiFactorConfig!.providerConfigs![0].state, + equals(MultiFactorConfigState.enabled), + ); + } + } + } finally { + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv}); + }, + skip: hasGoogleEnv + ? false + : 'Requires GCIP (Google Cloud Identity Platform)', + ); + + test( + 'updates TOTP provider config with adjacentIntervals', + () { + // Remove emulator env var from the zone environment + final prodEnv = Map.from(Platform.environment); + prodEnv.remove(Environment.firebaseAuthEmulatorHost); + + return runZoned(() async { + final appName = + 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final testAuth = Auth(app); + final projectConfigManager = testAuth.projectConfigManager; + + try { + final updatedConfig = await projectConfigManager + .updateProjectConfig( + UpdateProjectConfigRequest( + multiFactorConfig: MultiFactorConfig( + state: MultiFactorConfigState.enabled, + providerConfigs: [ + MultiFactorProviderConfig( + state: MultiFactorConfigState.enabled, + totpProviderConfig: TotpMultiFactorProviderConfig( + adjacentIntervals: 5, + ), + ), + ], + ), + ), + ); + + expect(updatedConfig, isA()); + + if (updatedConfig.multiFactorConfig != null) { + final providerConfigs = + updatedConfig.multiFactorConfig!.providerConfigs; + if (providerConfigs != null && providerConfigs.isNotEmpty) { + expect( + providerConfigs[0].totpProviderConfig?.adjacentIntervals, + equals(5), + ); + } + } + } finally { + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv}); + }, + skip: hasGoogleEnv + ? false + : 'Requires GCIP (Google Cloud Identity Platform)', + ); + + test( + 'updates MFA with both SMS and TOTP enabled', + () { + // Remove emulator env var from the zone environment + final prodEnv = Map.from(Platform.environment); + prodEnv.remove(Environment.firebaseAuthEmulatorHost); + + return runZoned(() async { + final appName = + 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final testAuth = Auth(app); + final projectConfigManager = testAuth.projectConfigManager; + + try { + final updatedConfig = await projectConfigManager + .updateProjectConfig( + UpdateProjectConfigRequest( + multiFactorConfig: MultiFactorConfig( + state: MultiFactorConfigState.enabled, + factorIds: ['phone'], + providerConfigs: [ + MultiFactorProviderConfig( + state: MultiFactorConfigState.enabled, + totpProviderConfig: TotpMultiFactorProviderConfig( + adjacentIntervals: 3, + ), + ), + ], + ), + ), + ); + + expect(updatedConfig, isA()); + + if (updatedConfig.multiFactorConfig != null) { + expect( + updatedConfig.multiFactorConfig!.state, + equals(MultiFactorConfigState.enabled), + ); + expect( + updatedConfig.multiFactorConfig!.factorIds, + contains('phone'), + ); + final providerConfigs = + updatedConfig.multiFactorConfig!.providerConfigs; + if (providerConfigs != null && providerConfigs.isNotEmpty) { + expect( + providerConfigs[0].state, + equals(MultiFactorConfigState.enabled), + ); + } + } + } finally { + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv}); + }, + skip: hasGoogleEnv + ? false + : 'Requires GCIP (Google Cloud Identity Platform)', + ); + + test( + 'updates TOTP provider config with disabled state', + () { + // Remove emulator env var from the zone environment + final prodEnv = Map.from(Platform.environment); + prodEnv.remove(Environment.firebaseAuthEmulatorHost); + + return runZoned(() async { + final appName = + 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final testAuth = Auth(app); + final projectConfigManager = testAuth.projectConfigManager; + + try { + final updatedConfig = await projectConfigManager + .updateProjectConfig( + UpdateProjectConfigRequest( + multiFactorConfig: MultiFactorConfig( + state: MultiFactorConfigState.enabled, + providerConfigs: [ + MultiFactorProviderConfig( + state: MultiFactorConfigState.disabled, + totpProviderConfig: TotpMultiFactorProviderConfig(), + ), + ], + ), + ), + ); + + expect(updatedConfig, isA()); + + if (updatedConfig.multiFactorConfig != null) { + final providerConfigs = + updatedConfig.multiFactorConfig!.providerConfigs; + if (providerConfigs != null && providerConfigs.isNotEmpty) { + expect( + providerConfigs[0].state, + equals(MultiFactorConfigState.disabled), + ); + } + } + } finally { + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv}); + }, + skip: hasGoogleEnv + ? false + : 'Requires GCIP (Google Cloud Identity Platform)', + ); + }); + + group('updateProjectConfig - reCAPTCHA', () { + test( + 'updates reCAPTCHA configuration', + () { + // Remove emulator env var from the zone environment + final prodEnv = Map.from(Platform.environment); + prodEnv.remove(Environment.firebaseAuthEmulatorHost); + + return runZoned(() async { + final appName = + 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final testAuth = Auth(app); + final projectConfigManager = testAuth.projectConfigManager; + + try { + // Note: phoneEnforcementState requires useSmsBotScore or useSmsTollFraudProtection + // to be enabled, which are not yet supported in the Dart SDK. + // Testing only emailPasswordEnforcementState which doesn't have this requirement. + final updatedConfig = await projectConfigManager.updateProjectConfig( + UpdateProjectConfigRequest( + recaptchaConfig: RecaptchaConfig( + emailPasswordEnforcementState: + RecaptchaProviderEnforcementState.enforce, + // phoneEnforcementState requires toll fraud or bot score enablement + // which is not yet supported in the Dart SDK + ), + ), + ); + + expect(updatedConfig, isA()); + + if (updatedConfig.recaptchaConfig != null) { + expect( + updatedConfig.recaptchaConfig!.emailPasswordEnforcementState, + equals(RecaptchaProviderEnforcementState.enforce), + ); + } + } finally { + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv}); + }, + skip: hasGoogleEnv + ? false + : 'Requires reCAPTCHA Enterprise configuration', + ); + }); + + group('updateProjectConfig - Combined', () { + test( + 'updates multiple configuration fields at once', + () { + // Remove emulator env var from the zone environment + final prodEnv = Map.from(Platform.environment); + prodEnv.remove(Environment.firebaseAuthEmulatorHost); + + return runZoned(() async { + final appName = + 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final testAuth = Auth(app); + final projectConfigManager = testAuth.projectConfigManager; + + try { + final updatedConfig = await projectConfigManager + .updateProjectConfig( + UpdateProjectConfigRequest( + emailPrivacyConfig: EmailPrivacyConfig( + enableImprovedEmailPrivacy: true, + ), + multiFactorConfig: MultiFactorConfig( + state: MultiFactorConfigState.enabled, + factorIds: ['phone'], + ), + mobileLinksConfig: const MobileLinksConfig( + domain: MobileLinksDomain.firebaseDynamicLinkDomain, + ), + ), + ); + + expect(updatedConfig, isA()); + + if (updatedConfig.emailPrivacyConfig != null) { + expect( + updatedConfig.emailPrivacyConfig!.enableImprovedEmailPrivacy, + isTrue, + ); + } + if (updatedConfig.multiFactorConfig != null) { + expect( + updatedConfig.multiFactorConfig!.state, + equals(MultiFactorConfigState.enabled), + ); + } + if (updatedConfig.mobileLinksConfig != null) { + expect( + updatedConfig.mobileLinksConfig!.domain, + equals(MobileLinksDomain.firebaseDynamicLinkDomain), + ); + } + } finally { + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv}); + }, + skip: hasGoogleEnv + ? false + : 'Requires GCIP (Google Cloud Identity Platform)', + ); + }); + }); +} diff --git a/packages/dart_firebase_admin/test/auth/project_config_integration_test.dart b/packages/dart_firebase_admin/test/auth/project_config_integration_test.dart index eb8ae6e5..f92be7a6 100644 --- a/packages/dart_firebase_admin/test/auth/project_config_integration_test.dart +++ b/packages/dart_firebase_admin/test/auth/project_config_integration_test.dart @@ -1,82 +1,36 @@ +// Firebase ProjectConfig Integration Tests - Emulator Safe +// +// These tests work with Firebase Auth Emulator and test basic ProjectConfig operations. +// For production-only tests (MFA, TOTP, reCAPTCHA), see project_config_integration_prod_test.dart +// +// Run with: +// FIREBASE_AUTH_EMULATOR_HOST=localhost:9099 dart test test/auth/project_config_integration_test.dart + import 'package:dart_firebase_admin/auth.dart'; -import 'package:dart_firebase_admin/src/app.dart'; import 'package:test/test.dart'; -import '../google_cloud_firestore/util/helpers.dart'; +import 'util/helpers.dart'; void main() { late Auth auth; late ProjectConfigManager projectConfigManager; - ProjectConfig? originalConfig; setUp(() { - final app = createApp(); - auth = Auth(app); + auth = createAuthForTest(); projectConfigManager = auth.projectConfigManager; }); group('ProjectConfigManager', () { - // Save original config before running update tests - setUpAll(() async { - if (hasGoogleEnv) { - final app = FirebaseApp.initializeApp( - name: 'save-config-app', - options: const AppOptions(projectId: projectId), - ); - final testAuth = Auth(app); - try { - originalConfig = await testAuth.projectConfigManager - .getProjectConfig(); - // ignore: avoid_print - print('Original config saved for restoration after tests'); - } finally { - await app.close(); - } - } - }); - - // Restore original config after all tests complete - tearDownAll(() async { - if (hasGoogleEnv && originalConfig != null) { - final app = FirebaseApp.initializeApp( - name: 'restore-config-app', - options: const AppOptions(projectId: projectId), - ); - final testAuth = Auth(app); - try { - await testAuth.projectConfigManager.updateProjectConfig( - UpdateProjectConfigRequest( - smsRegionConfig: originalConfig!.smsRegionConfig, - multiFactorConfig: originalConfig!.multiFactorConfig, - recaptchaConfig: originalConfig!.recaptchaConfig, - passwordPolicyConfig: originalConfig!.passwordPolicyConfig, - emailPrivacyConfig: originalConfig!.emailPrivacyConfig, - mobileLinksConfig: originalConfig!.mobileLinksConfig, - ), - ); - // ignore: avoid_print - print('Original config restored successfully'); - } finally { - await app.close(); - } - } - }); group('getProjectConfig', () { - test( - 'retrieves current project configuration', - () async { - final config = await projectConfigManager.getProjectConfig(); + test('retrieves current project configuration', () async { + final config = await projectConfigManager.getProjectConfig(); - // ProjectConfig should always be returned, even if fields are null - expect(config, isA()); + // ProjectConfig should always be returned, even if fields are null + expect(config, isA()); - // Depending on project setup, some fields may or may not be configured - // We just verify the response structure is correct - }, - // skip: hasGoogleEnv - // ? false - // : 'Requires GOOGLE_APPLICATION_CREDENTIALS - ProjectConfig not supported in Auth emulator', - ); + // Depending on project setup, some fields may or may not be configured + // We just verify the response structure is correct + }); test('returns config with proper types for all fields', () async { final config = await projectConfigManager.getProjectConfig(); @@ -199,57 +153,6 @@ void main() { } }); - test( - 'updates multi-factor authentication configuration', - () async { - final updatedConfig = await projectConfigManager.updateProjectConfig( - UpdateProjectConfigRequest( - multiFactorConfig: MultiFactorConfig( - state: MultiFactorConfigState.enabled, - factorIds: ['phone'], - ), - ), - ); - - expect(updatedConfig, isA()); - - if (updatedConfig.multiFactorConfig != null) { - expect( - updatedConfig.multiFactorConfig!.state, - equals(MultiFactorConfigState.enabled), - ); - } - }, - skip: - 'Requires GCIP (Google Cloud Identity Platform) - MFA not available in standard Firebase Auth', - ); - - test( - 'updates reCAPTCHA configuration', - () async { - final updatedConfig = await projectConfigManager.updateProjectConfig( - UpdateProjectConfigRequest( - recaptchaConfig: RecaptchaConfig( - emailPasswordEnforcementState: - RecaptchaProviderEnforcementState.enforce, - phoneEnforcementState: RecaptchaProviderEnforcementState.audit, - ), - ), - ); - - expect(updatedConfig, isA()); - - if (updatedConfig.recaptchaConfig != null) { - expect( - updatedConfig.recaptchaConfig!.emailPasswordEnforcementState, - equals(RecaptchaProviderEnforcementState.enforce), - ); - } - }, - skip: - 'Requires reCAPTCHA Enterprise configuration - phone auth enforcement must align with toll fraud settings', - ); - test('updates password policy configuration', () async { final updatedConfig = await projectConfigManager.updateProjectConfig( UpdateProjectConfigRequest( @@ -295,49 +198,6 @@ void main() { } }); - test( - 'updates multiple configuration fields at once', - () async { - final updatedConfig = await projectConfigManager.updateProjectConfig( - UpdateProjectConfigRequest( - emailPrivacyConfig: EmailPrivacyConfig( - enableImprovedEmailPrivacy: true, - ), - multiFactorConfig: MultiFactorConfig( - state: MultiFactorConfigState.enabled, - factorIds: ['phone'], - ), - mobileLinksConfig: const MobileLinksConfig( - domain: MobileLinksDomain.firebaseDynamicLinkDomain, - ), - ), - ); - - expect(updatedConfig, isA()); - - if (updatedConfig.emailPrivacyConfig != null) { - expect( - updatedConfig.emailPrivacyConfig!.enableImprovedEmailPrivacy, - isTrue, - ); - } - if (updatedConfig.multiFactorConfig != null) { - expect( - updatedConfig.multiFactorConfig!.state, - equals(MultiFactorConfigState.enabled), - ); - } - if (updatedConfig.mobileLinksConfig != null) { - expect( - updatedConfig.mobileLinksConfig!.domain, - equals(MobileLinksDomain.firebaseDynamicLinkDomain), - ); - } - }, - skip: - 'Requires GCIP (Google Cloud Identity Platform) - includes MFA configuration', - ); - test('get and update maintain consistency', () async { final initialConfig = await projectConfigManager.getProjectConfig(); diff --git a/packages/dart_firebase_admin/test/auth/tenant_integration_prod_test.dart b/packages/dart_firebase_admin/test/auth/tenant_integration_prod_test.dart new file mode 100644 index 00000000..65112877 --- /dev/null +++ b/packages/dart_firebase_admin/test/auth/tenant_integration_prod_test.dart @@ -0,0 +1,778 @@ +// Firebase Tenant Integration Tests - Production Only +// +// These tests require production Firebase (GOOGLE_APPLICATION_CREDENTIALS) +// because they test features not available in the emulator: +// - Multi-factor authentication with TOTP (requires GCIP) +// - Tenant-scoped user operations (not fully supported in emulator) +// +// **REQUIREMENTS:** +// 1. Production Firebase project with multi-tenancy ENABLED +// - Enable multi-tenancy in Firebase Console: Authentication > Settings > Multi-tenancy +// - Or enable Google Cloud Identity Platform (GCIP) for your project +// 2. GOOGLE_APPLICATION_CREDENTIALS environment variable set +// +// **IMPORTANT:** These tests use runZoned with zoneValues to temporarily +// disable the emulator environment variable. This allows them to run in the +// coverage script (which has emulator vars set) by connecting to production +// only for these specific tests. +// +// For basic tenant operations that work with the emulator, see tenant_integration_test.dart +// +// Run standalone with: +// GOOGLE_APPLICATION_CREDENTIALS=service-account-key.json dart test test/auth/tenant_integration_prod_test.dart + +import 'dart:async'; +import 'dart:io'; + +import 'package:dart_firebase_admin/auth.dart'; +import 'package:dart_firebase_admin/src/app.dart'; +import 'package:googleapis/identitytoolkit/v1.dart'; +import 'package:test/test.dart'; +import 'package:uuid/uuid.dart'; + +import '../google_cloud_firestore/util/helpers.dart'; + +const _uid = Uuid(); + +void main() { + group('TenantManager (Production)', () { + group('createTenant - TOTP/MFA', () { + test( + 'creates tenant with TOTP provider config', + () { + // Remove emulator env var from the zone environment + final prodEnv = Map.from(Platform.environment); + prodEnv.remove(Environment.firebaseAuthEmulatorHost); + + return runZoned(() async { + final appName = + 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final testAuth = Auth(app); + final tenantManager = testAuth.tenantManager; + + Tenant? tenant; + try { + tenant = await tenantManager.createTenant( + CreateTenantRequest( + displayName: 'TOTP-Tenant', + multiFactorConfig: MultiFactorConfig( + state: MultiFactorConfigState.enabled, + providerConfigs: [ + MultiFactorProviderConfig( + state: MultiFactorConfigState.enabled, + totpProviderConfig: TotpMultiFactorProviderConfig( + adjacentIntervals: 5, + ), + ), + ], + ), + ), + ); + + expect(tenant.tenantId, isNotEmpty); + expect(tenant.displayName, equals('TOTP-Tenant')); + + if (tenant.multiFactorConfig != null) { + expect( + tenant.multiFactorConfig!.state, + equals(MultiFactorConfigState.enabled), + ); + final providerConfigs = + tenant.multiFactorConfig!.providerConfigs; + if (providerConfigs != null && providerConfigs.isNotEmpty) { + expect( + providerConfigs[0].state, + equals(MultiFactorConfigState.enabled), + ); + expect( + providerConfigs[0].totpProviderConfig?.adjacentIntervals, + equals(5), + ); + } + } + } finally { + if (tenant != null) { + await tenantManager.deleteTenant(tenant.tenantId); + } + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv}); + }, + skip: hasGoogleEnv + ? false + : 'Requires GCIP (Google Cloud Identity Platform)', + ); + + test( + 'creates tenant with both SMS and TOTP MFA', + () { + // Remove emulator env var from the zone environment + final prodEnv = Map.from(Platform.environment); + prodEnv.remove(Environment.firebaseAuthEmulatorHost); + + return runZoned(() async { + final appName = + 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final testAuth = Auth(app); + final tenantManager = testAuth.tenantManager; + + Tenant? tenant; + try { + tenant = await tenantManager.createTenant( + CreateTenantRequest( + displayName: 'Combined-MFA-Tenant', + multiFactorConfig: MultiFactorConfig( + state: MultiFactorConfigState.enabled, + factorIds: ['phone'], + providerConfigs: [ + MultiFactorProviderConfig( + state: MultiFactorConfigState.enabled, + totpProviderConfig: TotpMultiFactorProviderConfig( + adjacentIntervals: 3, + ), + ), + ], + ), + ), + ); + + expect(tenant.tenantId, isNotEmpty); + + if (tenant.multiFactorConfig != null) { + expect( + tenant.multiFactorConfig!.state, + equals(MultiFactorConfigState.enabled), + ); + expect(tenant.multiFactorConfig!.factorIds, contains('phone')); + final providerConfigs = + tenant.multiFactorConfig!.providerConfigs; + if (providerConfigs != null && providerConfigs.isNotEmpty) { + expect( + providerConfigs[0].totpProviderConfig?.adjacentIntervals, + equals(3), + ); + } + } + } finally { + if (tenant != null) { + await tenantManager.deleteTenant(tenant.tenantId); + } + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv}); + }, + skip: hasGoogleEnv + ? false + : 'Requires GCIP (Google Cloud Identity Platform)', + ); + }); + + group('updateTenant - TOTP/MFA', () { + test( + 'updates tenant with TOTP provider config', + () { + // Remove emulator env var from the zone environment + final prodEnv = Map.from(Platform.environment); + prodEnv.remove(Environment.firebaseAuthEmulatorHost); + + return runZoned(() async { + final appName = + 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final testAuth = Auth(app); + final tenantManager = testAuth.tenantManager; + + Tenant? tenant; + try { + tenant = await tenantManager.createTenant( + CreateTenantRequest(displayName: 'TOTP-Update-Test'), + ); + + final updatedTenant = await tenantManager.updateTenant( + tenant.tenantId, + UpdateTenantRequest( + multiFactorConfig: MultiFactorConfig( + state: MultiFactorConfigState.enabled, + providerConfigs: [ + MultiFactorProviderConfig( + state: MultiFactorConfigState.enabled, + totpProviderConfig: TotpMultiFactorProviderConfig( + adjacentIntervals: 7, + ), + ), + ], + ), + ), + ); + + expect(updatedTenant.tenantId, equals(tenant.tenantId)); + + if (updatedTenant.multiFactorConfig != null) { + final providerConfigs = + updatedTenant.multiFactorConfig!.providerConfigs; + if (providerConfigs != null && providerConfigs.isNotEmpty) { + expect( + providerConfigs[0].totpProviderConfig?.adjacentIntervals, + equals(7), + ); + } + } + } finally { + if (tenant != null) { + await tenantManager.deleteTenant(tenant.tenantId); + } + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv}); + }, + skip: hasGoogleEnv + ? false + : 'Requires GCIP (Google Cloud Identity Platform)', + ); + + test( + 'updates tenant with combined SMS and TOTP MFA', + () { + // Remove emulator env var from the zone environment + final prodEnv = Map.from(Platform.environment); + prodEnv.remove(Environment.firebaseAuthEmulatorHost); + + return runZoned(() async { + final appName = + 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final testAuth = Auth(app); + final tenantManager = testAuth.tenantManager; + + Tenant? tenant; + try { + tenant = await tenantManager.createTenant( + CreateTenantRequest(displayName: 'Combined-MFA-Update'), + ); + + final updatedTenant = await tenantManager.updateTenant( + tenant.tenantId, + UpdateTenantRequest( + multiFactorConfig: MultiFactorConfig( + state: MultiFactorConfigState.enabled, + factorIds: ['phone'], + providerConfigs: [ + MultiFactorProviderConfig( + state: MultiFactorConfigState.enabled, + totpProviderConfig: TotpMultiFactorProviderConfig( + adjacentIntervals: 5, + ), + ), + ], + ), + ), + ); + + expect(updatedTenant.tenantId, equals(tenant.tenantId)); + + if (updatedTenant.multiFactorConfig != null) { + expect( + updatedTenant.multiFactorConfig!.state, + equals(MultiFactorConfigState.enabled), + ); + expect( + updatedTenant.multiFactorConfig!.factorIds, + contains('phone'), + ); + final providerConfigs = + updatedTenant.multiFactorConfig!.providerConfigs; + if (providerConfigs != null && providerConfigs.isNotEmpty) { + expect( + providerConfigs[0].totpProviderConfig?.adjacentIntervals, + equals(5), + ); + } + } + } finally { + if (tenant != null) { + await tenantManager.deleteTenant(tenant.tenantId); + } + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv}); + }, + skip: hasGoogleEnv + ? false + : 'Requires GCIP (Google Cloud Identity Platform)', + ); + }); + + group('authForTenant - User Operations', () { + test( + 'tenant auth can create users', + () { + // Remove emulator env var from the zone environment + final prodEnv = Map.from(Platform.environment); + prodEnv.remove(Environment.firebaseAuthEmulatorHost); + + return runZoned(() async { + final appName = + 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final testAuth = Auth(app); + final tenantManager = testAuth.tenantManager; + + Tenant? tenant; + UserRecord? user; + try { + tenant = await tenantManager.createTenant( + CreateTenantRequest( + displayName: 'User-Creation-Test', + emailSignInConfig: EmailSignInProviderConfig( + enabled: true, + passwordRequired: false, + ), + ), + ); + + final tenantAuth = tenantManager.authForTenant(tenant.tenantId); + + // Use unique email to avoid conflicts with previous test runs + final timestamp = DateTime.now().millisecondsSinceEpoch; + final email = 'tenant-user-$timestamp@example.com'; + + user = await tenantAuth.createUser(CreateRequest(email: email)); + + expect(user.uid, isNotEmpty); + expect(user.email, equals(email)); + } finally { + if (user != null && tenant != null) { + final tenantAuth = tenantManager.authForTenant(tenant.tenantId); + await tenantAuth.deleteUser(user.uid); + } + if (tenant != null) { + await tenantManager.deleteTenant(tenant.tenantId); + } + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv}); + }, + skip: hasGoogleEnv ? false : 'Requires production Firebase', + ); + + test( + 'tenant auth can list users', + () { + // Remove emulator env var from the zone environment + final prodEnv = Map.from(Platform.environment); + prodEnv.remove(Environment.firebaseAuthEmulatorHost); + + return runZoned(() async { + final appName = + 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final testAuth = Auth(app); + final tenantManager = testAuth.tenantManager; + + Tenant? tenant; + UserRecord? user1; + UserRecord? user2; + try { + tenant = await tenantManager.createTenant( + CreateTenantRequest( + displayName: 'List-Users-Test', + emailSignInConfig: EmailSignInProviderConfig( + enabled: true, + passwordRequired: false, + ), + ), + ); + + final tenantAuth = tenantManager.authForTenant(tenant.tenantId); + + // Clean up any existing users in the tenant from previous test runs + final existingUsers = await tenantAuth.listUsers(); + await Future.wait([ + for (final existingUser in existingUsers.users) + tenantAuth.deleteUser(existingUser.uid), + ]); + + // Use unique emails to avoid conflicts with previous test runs + final timestamp = DateTime.now().millisecondsSinceEpoch; + + // Create multiple users + user1 = await tenantAuth.createUser( + CreateRequest(email: 'user1-$timestamp@example.com'), + ); + user2 = await tenantAuth.createUser( + CreateRequest(email: 'user2-$timestamp@example.com'), + ); + + final users = await tenantAuth.listUsers(); + + expect(users.users.length, equals(2)); + expect( + users.users.map((u) => u.uid), + containsAll([user1.uid, user2.uid]), + ); + } finally { + if (tenant != null) { + final tenantAuth = tenantManager.authForTenant(tenant.tenantId); + await Future.wait([ + if (user1 != null) tenantAuth.deleteUser(user1.uid), + if (user2 != null) tenantAuth.deleteUser(user2.uid), + ]); + await tenantManager.deleteTenant(tenant.tenantId); + } + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv}); + }, + skip: hasGoogleEnv ? false : 'Requires production Firebase', + ); + }); + + group('authForTenant - Session Cookies', () { + test( + 'tenant auth creates and verifies a valid session cookie', + () { + // Remove emulator env var from the zone environment + final prodEnv = Map.from(Platform.environment); + prodEnv.remove(Environment.firebaseAuthEmulatorHost); + + return runZoned(() async { + final appName = + 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final testAuth = Auth(app); + final tenantManager = testAuth.tenantManager; + + Tenant? tenant; + UserRecord? user; + try { + tenant = await tenantManager.createTenant( + CreateTenantRequest( + displayName: 'Session-Cookie-Test', + emailSignInConfig: EmailSignInProviderConfig( + enabled: true, + passwordRequired: false, + ), + ), + ); + + final tenantAuth = tenantManager.authForTenant(tenant.tenantId); + + // Helper function to exchange custom token for ID token (tenant-scoped) + Future getIdTokenFromCustomToken( + String customToken, + String tenantId, + ) async { + final client = await testAuth.app.client; + final api = IdentityToolkitApi(client); + + final request = + GoogleCloudIdentitytoolkitV1SignInWithCustomTokenRequest( + token: customToken, + returnSecureToken: true, + tenantId: tenantId, + ); + + final response = await api.accounts.signInWithCustomToken( + request, + ); + + if (response.idToken == null || response.idToken!.isEmpty) { + throw Exception( + 'Failed to exchange custom token for ID token: No idToken in response', + ); + } + + return response.idToken!; + } + + user = await tenantAuth.createUser(CreateRequest(uid: _uid.v4())); + + final customToken = await tenantAuth.createCustomToken(user.uid); + final idToken = await getIdTokenFromCustomToken( + customToken, + tenant.tenantId, + ); + + const expiresIn = 24 * 60 * 60 * 1000; // 24 hours + final sessionCookie = await tenantAuth.createSessionCookie( + idToken, + const SessionCookieOptions(expiresIn: expiresIn), + ); + + expect(sessionCookie, isNotEmpty); + + final decodedToken = await tenantAuth.verifySessionCookie( + sessionCookie, + ); + expect(decodedToken.uid, equals(user.uid)); + expect(decodedToken.iss, contains('session.firebase.google.com')); + expect(decodedToken.firebase.tenant, equals(tenant.tenantId)); + } finally { + if (tenant != null) { + final tenantAuth = tenantManager.authForTenant(tenant.tenantId); + if (user != null) { + await tenantAuth.deleteUser(user.uid); + } + await tenantManager.deleteTenant(tenant.tenantId); + } + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv}); + }, + skip: hasGoogleEnv + ? false + : 'Session cookies require GCIP (not available in emulator)', + ); + + test( + 'tenant auth creates a revocable session cookie', + () { + // Remove emulator env var from the zone environment + final prodEnv = Map.from(Platform.environment); + prodEnv.remove(Environment.firebaseAuthEmulatorHost); + + return runZoned(() async { + final appName = + 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final testAuth = Auth(app); + final tenantManager = testAuth.tenantManager; + + Tenant? tenant; + UserRecord? user; + try { + tenant = await tenantManager.createTenant( + CreateTenantRequest( + displayName: 'RevocableSession', + emailSignInConfig: EmailSignInProviderConfig( + enabled: true, + passwordRequired: false, + ), + ), + ); + + final tenantAuth = tenantManager.authForTenant(tenant.tenantId); + + // Helper function to exchange custom token for ID token (tenant-scoped) + Future getIdTokenFromCustomToken( + String customToken, + String tenantId, + ) async { + final client = await testAuth.app.client; + final api = IdentityToolkitApi(client); + + final request = + GoogleCloudIdentitytoolkitV1SignInWithCustomTokenRequest( + token: customToken, + returnSecureToken: true, + tenantId: tenantId, + ); + + final response = await api.accounts.signInWithCustomToken( + request, + ); + + if (response.idToken == null || response.idToken!.isEmpty) { + throw Exception( + 'Failed to exchange custom token for ID token: No idToken in response', + ); + } + + return response.idToken!; + } + + user = await tenantAuth.createUser(CreateRequest(uid: _uid.v4())); + + final customToken = await tenantAuth.createCustomToken(user.uid); + final idToken = await getIdTokenFromCustomToken( + customToken, + tenant.tenantId, + ); + + const expiresIn = 24 * 60 * 60 * 1000; + final sessionCookie = await tenantAuth.createSessionCookie( + idToken, + const SessionCookieOptions(expiresIn: expiresIn), + ); + + final decodedToken = await tenantAuth.verifySessionCookie( + sessionCookie, + ); + expect(decodedToken.uid, equals(user.uid)); + expect(decodedToken.firebase.tenant, equals(tenant.tenantId)); + + await Future.delayed(const Duration(seconds: 2)); + await tenantAuth.revokeRefreshTokens(user.uid); + + // Without checkRevoked, should not throw + await tenantAuth.verifySessionCookie(sessionCookie); + + // With checkRevoked: true, should throw + await expectLater( + () => tenantAuth.verifySessionCookie( + sessionCookie, + checkRevoked: true, + ), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/session-cookie-revoked', + ), + ), + ); + } finally { + if (tenant != null) { + final tenantAuth = tenantManager.authForTenant(tenant.tenantId); + if (user != null) { + await tenantAuth.deleteUser(user.uid); + } + await tenantManager.deleteTenant(tenant.tenantId); + } + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv}); + }, + skip: hasGoogleEnv + ? false + : 'Session cookies require GCIP (not available in emulator)', + ); + + test( + 'tenant auth verifySessionCookie rejects session cookie from different tenant', + () { + // Remove emulator env var from the zone environment + final prodEnv = Map.from(Platform.environment); + prodEnv.remove(Environment.firebaseAuthEmulatorHost); + + return runZoned(() async { + final appName = + 'prod-test-${DateTime.now().microsecondsSinceEpoch}'; + final app = FirebaseApp.initializeApp(name: appName); + final testAuth = Auth(app); + final tenantManager = testAuth.tenantManager; + + Tenant? tenant1; + Tenant? tenant2; + UserRecord? user1; + UserRecord? user2; + try { + // Create two tenants + tenant1 = await tenantManager.createTenant( + CreateTenantRequest( + displayName: 'Tenant1SessionCookie', + emailSignInConfig: EmailSignInProviderConfig( + enabled: true, + passwordRequired: false, + ), + ), + ); + + tenant2 = await tenantManager.createTenant( + CreateTenantRequest( + displayName: 'Tenant2SessionCookie', + emailSignInConfig: EmailSignInProviderConfig( + enabled: true, + passwordRequired: false, + ), + ), + ); + + final tenantAuth1 = tenantManager.authForTenant(tenant1.tenantId); + final tenantAuth2 = tenantManager.authForTenant(tenant2.tenantId); + + // Helper function to exchange custom token for ID token (tenant-scoped) + Future getIdTokenFromCustomToken( + String customToken, + String tenantId, + ) async { + final client = await testAuth.app.client; + final api = IdentityToolkitApi(client); + + final request = + GoogleCloudIdentitytoolkitV1SignInWithCustomTokenRequest( + token: customToken, + returnSecureToken: true, + tenantId: tenantId, + ); + + final response = await api.accounts.signInWithCustomToken( + request, + ); + + if (response.idToken == null || response.idToken!.isEmpty) { + throw Exception( + 'Failed to exchange custom token for ID token: No idToken in response', + ); + } + + return response.idToken!; + } + + // Create users in both tenants + user1 = await tenantAuth1.createUser( + CreateRequest(uid: _uid.v4()), + ); + user2 = await tenantAuth2.createUser( + CreateRequest(uid: _uid.v4()), + ); + + // Create session cookie for tenant1 user + final customToken1 = await tenantAuth1.createCustomToken( + user1.uid, + ); + final idToken1 = await getIdTokenFromCustomToken( + customToken1, + tenant1.tenantId, + ); + + const expiresIn = 24 * 60 * 60 * 1000; + final sessionCookie1 = await tenantAuth1.createSessionCookie( + idToken1, + const SessionCookieOptions(expiresIn: expiresIn), + ); + + // Try to verify tenant1's session cookie with tenant2's auth + // This should fail because the tenant IDs don't match + await expectLater( + () => tenantAuth2.verifySessionCookie(sessionCookie1), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/mismatching-tenant-id', + ), + ), + ); + } finally { + if (tenant1 != null) { + final tenantAuth1 = tenantManager.authForTenant( + tenant1.tenantId, + ); + if (user1 != null) { + await tenantAuth1.deleteUser(user1.uid); + } + await tenantManager.deleteTenant(tenant1.tenantId); + } + if (tenant2 != null) { + final tenantAuth2 = tenantManager.authForTenant( + tenant2.tenantId, + ); + if (user2 != null) { + await tenantAuth2.deleteUser(user2.uid); + } + await tenantManager.deleteTenant(tenant2.tenantId); + } + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv}); + }, + skip: hasGoogleEnv + ? false + : 'Session cookies require GCIP (not available in emulator)', + ); + }); + }); +} diff --git a/packages/dart_firebase_admin/test/auth/tenant_integration_test.dart b/packages/dart_firebase_admin/test/auth/tenant_integration_test.dart index c96c58a0..4091039b 100644 --- a/packages/dart_firebase_admin/test/auth/tenant_integration_test.dart +++ b/packages/dart_firebase_admin/test/auth/tenant_integration_test.dart @@ -1,16 +1,23 @@ +// Firebase Tenant Integration Tests - Emulator Safe +// +// These tests work with Firebase Auth Emulator and test basic Tenant operations. +// For production-only tests (TOTP/MFA, tenant auth operations), see tenant_integration_prod_test.dart +// +// Run with: +// FIREBASE_AUTH_EMULATOR_HOST=localhost:9099 dart test test/auth/tenant_integration_test.dart + import 'package:dart_firebase_admin/auth.dart'; import 'package:dart_firebase_admin/src/app.dart'; import 'package:test/test.dart'; -import '../google_cloud_firestore/util/helpers.dart'; +import 'util/helpers.dart'; void main() { late Auth auth; late TenantManager tenantManager; setUp(() { - final sdk = createApp(tearDown: () => cleanup(auth)); - auth = Auth(sdk); + auth = createAuthForTest(); tenantManager = auth.tenantManager; }); @@ -79,15 +86,12 @@ void main() { // Note: The Firebase Auth Emulator may not support all advanced configuration // fields. These assertions are optional and will pass if the emulator // doesn't return these fields. - // In production, these fields should be properly supported. if (tenant.testPhoneNumbers != null) { expect(tenant.testPhoneNumbers!['+11234567890'], equals('123456')); } if (tenant.smsRegionConfig != null) { expect(tenant.smsRegionConfig, isA()); } - // recaptchaConfig, passwordPolicyConfig, and emailPrivacyConfig - // may not be supported by the emulator }); test('throws on invalid display name', () async { @@ -304,83 +308,6 @@ void main() { expect(tenantAuth.tenantId, equals(tenant.tenantId)); }); - test('tenant auth can create users', () async { - // Note: Firebase Auth Emulator does not fully support tenant-scoped - // user operations. Skip this test for emulator. - // See: https://firebase.google.com/docs/emulator-suite/connect_auth - if (Environment.isAuthEmulatorEnabled()) { - return; - } - - final tenant = await tenantManager.createTenant( - CreateTenantRequest( - displayName: 'User Creation Test', - emailSignInConfig: EmailSignInProviderConfig( - enabled: true, - passwordRequired: false, - ), - ), - ); - - final tenantAuth = tenantManager.authForTenant(tenant.tenantId); - - // Use unique email to avoid conflicts with previous test runs - final timestamp = DateTime.now().millisecondsSinceEpoch; - final email = 'tenant-user-$timestamp@example.com'; - - final user = await tenantAuth.createUser(CreateRequest(email: email)); - - expect(user.uid, isNotEmpty); - expect(user.email, equals(email)); - - // Cleanup: Delete the user - await tenantAuth.deleteUser(user.uid); - }); - - test('tenant auth can list users', () async { - // Note: Firebase Auth Emulator does not fully support tenant-scoped - // user operations. Skip this test for emulator. - // See: https://firebase.google.com/docs/emulator-suite/connect_auth - if (Environment.isAuthEmulatorEnabled()) { - return; - } - - final tenant = await tenantManager.createTenant( - CreateTenantRequest( - displayName: 'List Users Test', - emailSignInConfig: EmailSignInProviderConfig( - enabled: true, - passwordRequired: false, - ), - ), - ); - - final tenantAuth = tenantManager.authForTenant(tenant.tenantId); - - // Use unique emails to avoid conflicts with previous test runs - final timestamp = DateTime.now().millisecondsSinceEpoch; - - // Create multiple users - final user1 = await tenantAuth.createUser( - CreateRequest(email: 'user1-$timestamp@example.com'), - ); - final user2 = await tenantAuth.createUser( - CreateRequest(email: 'user2-$timestamp@example.com'), - ); - - final users = await tenantAuth.listUsers(); - - expect(users.users.length, equals(2)); - expect( - users.users.map((u) => u.uid), - containsAll([user1.uid, user2.uid]), - ); - - // Cleanup: Delete the users - await tenantAuth.deleteUser(user1.uid); - await tenantAuth.deleteUser(user2.uid); - }); - test('throws on empty tenant ID', () { expect( () => tenantManager.authForTenant(''), @@ -390,28 +317,3 @@ void main() { }); }); } - -Future cleanup(Auth auth) async { - if (!Environment.isAuthEmulatorEnabled()) { - throw Exception('Cannot cleanup non-emulator app'); - } - - final tenantManager = auth.tenantManager; - - // List all tenants and delete them - var result = await tenantManager.listTenants(maxResults: 100); - - while (true) { - await Future.wait([ - for (final tenant in result.tenants) - tenantManager.deleteTenant(tenant.tenantId), - ]); - - if (result.pageToken == null) break; - - result = await tenantManager.listTenants( - maxResults: 100, - pageToken: result.pageToken, - ); - } -} diff --git a/packages/dart_firebase_admin/test/auth/tenant_manager_test.dart b/packages/dart_firebase_admin/test/auth/tenant_manager_test.dart index 205738a3..4b5b32bd 100644 --- a/packages/dart_firebase_admin/test/auth/tenant_manager_test.dart +++ b/packages/dart_firebase_admin/test/auth/tenant_manager_test.dart @@ -1,10 +1,21 @@ +import 'dart:convert'; + import 'package:dart_firebase_admin/auth.dart'; import 'package:dart_firebase_admin/dart_firebase_admin.dart'; +import 'package:http/http.dart'; +import 'package:mocktail/mocktail.dart'; import 'package:test/test.dart'; +import '../google_cloud_firestore/util/helpers.dart'; +import '../mock.dart'; import '../mock_service_account.dart'; void main() { + setUpAll(() { + registerFallbackValue(CreateTenantRequest()); + registerFallbackValue(UpdateTenantRequest()); + }); + group('TenantManager', () { group('authForTenant', () { test('returns TenantAwareAuth instance for valid tenant ID', () { @@ -63,6 +74,377 @@ void main() { expect(identical(tenantManager1, tenantManager2), isTrue); }); + + group('getTenant', () { + test('throws when tenantId is empty', () async { + final app = _createMockApp(); + final tenantManager = TenantManager.internal(app); + + await expectLater( + () => tenantManager.getTenant(''), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/invalid-tenant-id', + ), + ), + ); + }); + + test('returns tenant successfully', () async { + final app = _createMockApp(); + final mockRequestHandler = AuthRequestHandlerMock(); + final tenantManager = TenantManager.internal( + app, + authRequestHandler: mockRequestHandler, + ); + + final tenantResponse = { + 'name': 'projects/test-project/tenants/test-tenant-id', + 'displayName': 'Test Tenant', + 'allowPasswordSignup': true, + 'enableEmailLinkSignin': false, + 'enableAnonymousUser': true, + }; + + when( + () => mockRequestHandler.getTenant(any()), + ).thenAnswer((_) async => tenantResponse); + + final tenant = await tenantManager.getTenant('test-tenant-id'); + + expect(tenant.tenantId, equals('test-tenant-id')); + expect(tenant.displayName, equals('Test Tenant')); + expect(tenant.anonymousSignInEnabled, isTrue); + verify(() => mockRequestHandler.getTenant('test-tenant-id')).called(1); + }); + + test('throws when backend returns error', () async { + final app = _createMockApp(); + final mockRequestHandler = AuthRequestHandlerMock(); + final tenantManager = TenantManager.internal( + app, + authRequestHandler: mockRequestHandler, + ); + + final error = FirebaseAuthAdminException( + AuthClientErrorCode.tenantNotFound, + 'TENANT_NOT_FOUND', + ); + + when(() => mockRequestHandler.getTenant(any())).thenThrow(error); + + await expectLater( + () => tenantManager.getTenant('test-tenant-id'), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/tenant-not-found', + ), + ), + ); + }); + }); + + group('listTenants', () { + test('throws when maxResults is too large', () async { + final app = _createMockApp(); + final tenantManager = TenantManager.internal(app); + + await expectLater( + () => tenantManager.listTenants(maxResults: 1001), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/argument-error', + ), + ), + ); + }); + + test('returns tenants successfully', () async { + final app = _createMockApp(); + final mockRequestHandler = AuthRequestHandlerMock(); + final tenantManager = TenantManager.internal( + app, + authRequestHandler: mockRequestHandler, + ); + + final listResponse = { + 'tenants': [ + { + 'name': 'projects/test-project/tenants/tenant-1', + 'displayName': 'Tenant 1', + }, + { + 'name': 'projects/test-project/tenants/tenant-2', + 'displayName': 'Tenant 2', + }, + ], + 'nextPageToken': 'next-page-token', + }; + + when( + () => mockRequestHandler.listTenants( + maxResults: any(named: 'maxResults'), + pageToken: any(named: 'pageToken'), + ), + ).thenAnswer((_) async => listResponse); + + final result = await tenantManager.listTenants( + maxResults: 10, + pageToken: 'page-token', + ); + + expect(result.tenants.length, equals(2)); + expect(result.tenants[0].tenantId, equals('tenant-1')); + expect(result.tenants[1].tenantId, equals('tenant-2')); + expect(result.pageToken, equals('next-page-token')); + verify( + () => mockRequestHandler.listTenants( + maxResults: 10, + pageToken: 'page-token', + ), + ).called(1); + }); + + test('returns empty list when no tenants', () async { + final app = _createMockApp(); + final mockRequestHandler = AuthRequestHandlerMock(); + final tenantManager = TenantManager.internal( + app, + authRequestHandler: mockRequestHandler, + ); + + final listResponse = { + 'tenants': >[], + }; + + when( + () => mockRequestHandler.listTenants( + maxResults: any(named: 'maxResults'), + pageToken: any(named: 'pageToken'), + ), + ).thenAnswer((_) async => listResponse); + + final result = await tenantManager.listTenants(); + + expect(result.tenants, isEmpty); + expect(result.pageToken, isNull); + }); + }); + + group('createTenant', () { + test('creates tenant successfully', () async { + final app = _createMockApp(); + final mockRequestHandler = AuthRequestHandlerMock(); + final tenantManager = TenantManager.internal( + app, + authRequestHandler: mockRequestHandler, + ); + + final tenantResponse = { + 'name': 'projects/test-project/tenants/new-tenant-id', + 'displayName': 'New Tenant', + 'allowPasswordSignup': true, + 'enableEmailLinkSignin': false, + 'enableAnonymousUser': false, + }; + + when( + () => mockRequestHandler.createTenant(any()), + ).thenAnswer((_) async => tenantResponse); + + final tenant = await tenantManager.createTenant( + CreateTenantRequest(displayName: 'New Tenant'), + ); + + expect(tenant.tenantId, equals('new-tenant-id')); + expect(tenant.displayName, equals('New Tenant')); + verify(() => mockRequestHandler.createTenant(any())).called(1); + }); + + test('throws when backend returns error', () async { + final app = _createMockApp(); + final mockRequestHandler = AuthRequestHandlerMock(); + final tenantManager = TenantManager.internal( + app, + authRequestHandler: mockRequestHandler, + ); + + final error = FirebaseAuthAdminException( + AuthClientErrorCode.internalError, + 'INTERNAL_ERROR', + ); + + when(() => mockRequestHandler.createTenant(any())).thenThrow(error); + + await expectLater( + () => tenantManager.createTenant( + CreateTenantRequest(displayName: 'New Tenant'), + ), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/internal-error', + ), + ), + ); + }); + }); + + group('updateTenant', () { + test('throws when tenantId is empty', () async { + final app = _createMockApp(); + final tenantManager = TenantManager.internal(app); + + await expectLater( + () => tenantManager.updateTenant( + '', + UpdateTenantRequest(displayName: 'Updated Name'), + ), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/invalid-tenant-id', + ), + ), + ); + }); + + test('updates tenant successfully', () async { + final app = _createMockApp(); + final mockRequestHandler = AuthRequestHandlerMock(); + final tenantManager = TenantManager.internal( + app, + authRequestHandler: mockRequestHandler, + ); + + final tenantResponse = { + 'name': 'projects/test-project/tenants/test-tenant-id', + 'displayName': 'Updated Tenant', + 'allowPasswordSignup': true, + 'enableEmailLinkSignin': false, + 'enableAnonymousUser': true, + }; + + when( + () => mockRequestHandler.updateTenant(any(), any()), + ).thenAnswer((_) async => tenantResponse); + + final tenant = await tenantManager.updateTenant( + 'test-tenant-id', + UpdateTenantRequest(displayName: 'Updated Tenant'), + ); + + expect(tenant.tenantId, equals('test-tenant-id')); + expect(tenant.displayName, equals('Updated Tenant')); + verify( + () => mockRequestHandler.updateTenant('test-tenant-id', any()), + ).called(1); + }); + + test('throws when backend returns error', () async { + final app = _createMockApp(); + final mockRequestHandler = AuthRequestHandlerMock(); + final tenantManager = TenantManager.internal( + app, + authRequestHandler: mockRequestHandler, + ); + + final error = FirebaseAuthAdminException( + AuthClientErrorCode.tenantNotFound, + 'TENANT_NOT_FOUND', + ); + + when( + () => mockRequestHandler.updateTenant(any(), any()), + ).thenThrow(error); + + await expectLater( + () => tenantManager.updateTenant( + 'test-tenant-id', + UpdateTenantRequest(displayName: 'Updated Name'), + ), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/tenant-not-found', + ), + ), + ); + }); + }); + + group('deleteTenant', () { + test('throws when tenantId is empty', () async { + final app = _createMockApp(); + final tenantManager = TenantManager.internal(app); + + await expectLater( + () => tenantManager.deleteTenant(''), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/invalid-tenant-id', + ), + ), + ); + }); + + test('deletes tenant successfully', () async { + final app = _createMockApp(); + final mockRequestHandler = AuthRequestHandlerMock(); + final tenantManager = TenantManager.internal( + app, + authRequestHandler: mockRequestHandler, + ); + + when( + () => mockRequestHandler.deleteTenant(any()), + ).thenAnswer((_) async => Future.value()); + + await tenantManager.deleteTenant('test-tenant-id'); + + verify( + () => mockRequestHandler.deleteTenant('test-tenant-id'), + ).called(1); + }); + + test('throws when backend returns error', () async { + final app = _createMockApp(); + final mockRequestHandler = AuthRequestHandlerMock(); + final tenantManager = TenantManager.internal( + app, + authRequestHandler: mockRequestHandler, + ); + + final error = FirebaseAuthAdminException( + AuthClientErrorCode.tenantNotFound, + 'TENANT_NOT_FOUND', + ); + + when(() => mockRequestHandler.deleteTenant(any())).thenThrow(error); + + await expectLater( + () => tenantManager.deleteTenant('test-tenant-id'), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/tenant-not-found', + ), + ), + ); + }); + }); }); group('ListTenantsResult', () { @@ -94,6 +476,8 @@ void main() { }); group('TenantAwareAuth', () { + setUpAll(registerFallbacks); + test('has correct tenant ID', () { final app = _createMockApp(); final auth = Auth(app); @@ -114,6 +498,974 @@ void main() { // TenantAwareAuth extends _BaseAuth which provides all auth methods expect(tenantAuth, isA()); }); + + group('verifyIdToken', () { + test('verifies ID token successfully with matching tenant ID', () async { + const tenantId = 'test-tenant-id'; + final mockIdTokenVerifier = MockFirebaseTokenVerifier(); + final decodedToken = DecodedIdToken.fromMap({ + 'sub': 'test-uid-123', + 'uid': 'test-uid-123', + 'aud': 'test-project', + 'iss': 'https://securetoken.google.com/test-project', + 'iat': DateTime.now().millisecondsSinceEpoch ~/ 1000, + 'exp': + DateTime.now() + .add(const Duration(hours: 1)) + .millisecondsSinceEpoch ~/ + 1000, + 'auth_time': DateTime.now().millisecondsSinceEpoch ~/ 1000, + 'firebase': { + 'identities': {}, + 'sign_in_provider': 'custom', + 'tenant': tenantId, + }, + }); + + when( + () => mockIdTokenVerifier.verifyJWT( + any(), + isEmulator: any(named: 'isEmulator'), + ), + ).thenAnswer((_) async => decodedToken); + + // Always mock HTTP client for getUser calls + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'users': [ + { + 'localId': 'test-uid-123', + 'email': 'test@example.com', + 'disabled': false, + 'createdAt': '1234567890000', + }, + ], + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp(client: clientMock, name: 'test-verify-id-token'); + final tenantAuth = TenantAwareAuth.internal( + app, + tenantId, + idTokenVerifier: mockIdTokenVerifier, + ); + + final result = await tenantAuth.verifyIdToken('mock-token'); + + expect(result.uid, equals('test-uid-123')); + expect(result.sub, equals('test-uid-123')); + expect(result.firebase.tenant, equals(tenantId)); + verify( + () => mockIdTokenVerifier.verifyJWT( + 'mock-token', + isEmulator: any(named: 'isEmulator'), + ), + ).called(1); + }); + + test('throws when idToken has mismatching tenant ID', () async { + const tenantId = 'test-tenant-id'; + const wrongTenantId = 'wrong-tenant-id'; + final mockIdTokenVerifier = MockFirebaseTokenVerifier(); + final decodedToken = DecodedIdToken.fromMap({ + 'sub': 'test-uid-123', + 'uid': 'test-uid-123', + 'aud': 'test-project', + 'iss': 'https://securetoken.google.com/test-project', + 'iat': DateTime.now().millisecondsSinceEpoch ~/ 1000, + 'exp': + DateTime.now() + .add(const Duration(hours: 1)) + .millisecondsSinceEpoch ~/ + 1000, + 'auth_time': DateTime.now().millisecondsSinceEpoch ~/ 1000, + 'firebase': { + 'identities': {}, + 'sign_in_provider': 'custom', + 'tenant': wrongTenantId, + }, + }); + + when( + () => mockIdTokenVerifier.verifyJWT( + any(), + isEmulator: any(named: 'isEmulator'), + ), + ).thenAnswer((_) async => decodedToken); + + // Mock HTTP client for getUser calls (needed when emulator is enabled or checkRevoked is true) + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'users': [ + { + 'localId': 'test-uid-123', + 'email': 'test@example.com', + 'disabled': false, + 'createdAt': '1234567890000', + }, + ], + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-mismatching-tenant-id', + ); + final tenantAuth = TenantAwareAuth.internal( + app, + tenantId, + idTokenVerifier: mockIdTokenVerifier, + ); + + await expectLater( + () => tenantAuth.verifyIdToken('mock-token'), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/mismatching-tenant-id', + ), + ), + ); + }); + + test('throws when idToken is empty', () async { + const tenantId = 'test-tenant-id'; + final mockIdTokenVerifier = MockFirebaseTokenVerifier(); + when( + () => mockIdTokenVerifier.verifyJWT( + any(), + isEmulator: any(named: 'isEmulator'), + ), + ).thenThrow( + FirebaseAuthAdminException( + AuthClientErrorCode.invalidArgument, + 'Firebase ID token has invalid format.', + ), + ); + + final app = createApp(name: 'test-verify-id-token-empty'); + final tenantAuth = TenantAwareAuth.internal( + app, + tenantId, + idTokenVerifier: mockIdTokenVerifier, + ); + + await expectLater( + () => tenantAuth.verifyIdToken(''), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/argument-error', + ), + ), + ); + }); + + test('throws when idToken is invalid', () async { + const tenantId = 'test-tenant-id'; + final mockIdTokenVerifier = MockFirebaseTokenVerifier(); + when( + () => mockIdTokenVerifier.verifyJWT( + any(), + isEmulator: any(named: 'isEmulator'), + ), + ).thenThrow( + FirebaseAuthAdminException( + AuthClientErrorCode.invalidArgument, + 'Decoding Firebase ID token failed.', + ), + ); + + final app = createApp(name: 'test-verify-id-token-invalid'); + final tenantAuth = TenantAwareAuth.internal( + app, + tenantId, + idTokenVerifier: mockIdTokenVerifier, + ); + + await expectLater( + () => tenantAuth.verifyIdToken('invalid-token'), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/argument-error', + ), + ), + ); + }); + + test('throws when checkRevoked is true and user is disabled', () async { + const tenantId = 'test-tenant-id'; + final mockIdTokenVerifier = MockFirebaseTokenVerifier(); + final decodedToken = DecodedIdToken.fromMap({ + 'sub': 'test-uid-123', + 'uid': 'test-uid-123', + 'aud': 'test-project', + 'iss': 'https://securetoken.google.com/test-project', + 'iat': DateTime.now().millisecondsSinceEpoch ~/ 1000, + 'exp': + DateTime.now() + .add(const Duration(hours: 1)) + .millisecondsSinceEpoch ~/ + 1000, + 'auth_time': DateTime.now().millisecondsSinceEpoch ~/ 1000, + 'firebase': { + 'identities': {}, + 'sign_in_provider': 'custom', + 'tenant': tenantId, + }, + }); + + when( + () => mockIdTokenVerifier.verifyJWT( + any(), + isEmulator: any(named: 'isEmulator'), + ), + ).thenAnswer((_) async => decodedToken); + + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'users': [ + { + 'localId': 'test-uid-123', + 'email': 'test@example.com', + 'disabled': true, + 'createdAt': '1234567890000', + }, + ], + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-verify-id-token-disabled', + ); + final tenantAuth = TenantAwareAuth.internal( + app, + tenantId, + idTokenVerifier: mockIdTokenVerifier, + ); + + await expectLater( + () => tenantAuth.verifyIdToken('mock-token', checkRevoked: true), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/user-disabled', + ), + ), + ); + }); + + test('throws when checkRevoked is true and token is revoked', () async { + const tenantId = 'test-tenant-id'; + final mockIdTokenVerifier = MockFirebaseTokenVerifier(); + // Token with auth_time before validSince + final authTime = DateTime.now().subtract(const Duration(hours: 2)); + final decodedToken = DecodedIdToken.fromMap({ + 'sub': 'test-uid-123', + 'uid': 'test-uid-123', + 'aud': 'test-project', + 'iss': 'https://securetoken.google.com/test-project', + 'iat': DateTime.now().millisecondsSinceEpoch ~/ 1000, + 'exp': + DateTime.now() + .add(const Duration(hours: 1)) + .millisecondsSinceEpoch ~/ + 1000, + 'auth_time': authTime.millisecondsSinceEpoch ~/ 1000, + 'firebase': { + 'identities': {}, + 'sign_in_provider': 'custom', + 'tenant': tenantId, + }, + }); + + when( + () => mockIdTokenVerifier.verifyJWT( + any(), + isEmulator: any(named: 'isEmulator'), + ), + ).thenAnswer((_) async => decodedToken); + + final clientMock = ClientMock(); + // validSince is after auth_time, so token is revoked + final validSince = DateTime.now().subtract(const Duration(hours: 1)); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'users': [ + { + 'localId': 'test-uid-123', + 'email': 'test@example.com', + 'disabled': false, + 'validSince': + (validSince.millisecondsSinceEpoch ~/ 1000) + .toString(), + 'createdAt': '1234567890000', + }, + ], + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-verify-id-token-revoked', + ); + final tenantAuth = TenantAwareAuth.internal( + app, + tenantId, + idTokenVerifier: mockIdTokenVerifier, + ); + + await expectLater( + () => tenantAuth.verifyIdToken('mock-token', checkRevoked: true), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/id-token-revoked', + ), + ), + ); + }); + + test( + 'succeeds when checkRevoked is true and token is not revoked', + () async { + const tenantId = 'test-tenant-id'; + final mockIdTokenVerifier = MockFirebaseTokenVerifier(); + // Token with auth_time after validSince + final authTime = DateTime.now().subtract(const Duration(minutes: 30)); + final decodedToken = DecodedIdToken.fromMap({ + 'sub': 'test-uid-123', + 'uid': 'test-uid-123', + 'aud': 'test-project', + 'iss': 'https://securetoken.google.com/test-project', + 'iat': DateTime.now().millisecondsSinceEpoch ~/ 1000, + 'exp': + DateTime.now() + .add(const Duration(hours: 1)) + .millisecondsSinceEpoch ~/ + 1000, + 'auth_time': authTime.millisecondsSinceEpoch ~/ 1000, + 'firebase': { + 'identities': {}, + 'sign_in_provider': 'custom', + 'tenant': tenantId, + }, + }); + + when( + () => mockIdTokenVerifier.verifyJWT( + any(), + isEmulator: any(named: 'isEmulator'), + ), + ).thenAnswer((_) async => decodedToken); + + final clientMock = ClientMock(); + // validSince is before auth_time, so token is not revoked + final validSince = DateTime.now().subtract(const Duration(hours: 1)); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'users': [ + { + 'localId': 'test-uid-123', + 'email': 'test@example.com', + 'disabled': false, + 'validSince': + (validSince.millisecondsSinceEpoch ~/ 1000) + .toString(), + 'createdAt': '1234567890000', + }, + ], + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-verify-id-token-not-revoked', + ); + final tenantAuth = TenantAwareAuth.internal( + app, + tenantId, + idTokenVerifier: mockIdTokenVerifier, + ); + + final result = await tenantAuth.verifyIdToken( + 'mock-token', + checkRevoked: true, + ); + + expect(result.uid, equals('test-uid-123')); + expect(result.firebase.tenant, equals(tenantId)); + }, + ); + }); + + group('createSessionCookie', () { + test('throws when idToken is empty', () async { + const tenantId = 'test-tenant-id'; + final app = _createMockApp(); + final auth = Auth(app); + final tenantManager = auth.tenantManager; + final tenantAuth = tenantManager.authForTenant(tenantId); + + expect( + () => tenantAuth.createSessionCookie( + '', + const SessionCookieOptions(expiresIn: 3600000), + ), + throwsA(isA()), + ); + }); + + test('validates expiresIn duration - too short', () async { + const tenantId = 'test-tenant-id'; + final mockIdTokenVerifier = MockFirebaseTokenVerifier(); + final decodedIdToken = DecodedIdToken.fromMap({ + 'sub': 'test-uid-123', + 'uid': 'test-uid-123', + 'aud': 'test-project', + 'iss': 'https://securetoken.google.com/test-project', + 'iat': DateTime.now().millisecondsSinceEpoch ~/ 1000, + 'exp': + DateTime.now() + .add(const Duration(hours: 1)) + .millisecondsSinceEpoch ~/ + 1000, + 'auth_time': DateTime.now().millisecondsSinceEpoch ~/ 1000, + 'firebase': { + 'identities': {}, + 'sign_in_provider': 'custom', + 'tenant': tenantId, + }, + }); + + when( + () => mockIdTokenVerifier.verifyJWT( + any(), + isEmulator: any(named: 'isEmulator'), + ), + ).thenAnswer((_) async => decodedIdToken); + + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({'sessionCookie': 'session-cookie-string'}), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = _createMockApp(); + final tenantAuth = TenantAwareAuth.internal( + app, + tenantId, + idTokenVerifier: mockIdTokenVerifier, + ); + + expect( + () => tenantAuth.createSessionCookie( + 'id-token', + const SessionCookieOptions( + expiresIn: 60000, + ), // 1 minute - too short + ), + throwsA(isA()), + ); + }); + + test('validates expiresIn duration - too long', () async { + const tenantId = 'test-tenant-id'; + final mockIdTokenVerifier = MockFirebaseTokenVerifier(); + final decodedIdToken = DecodedIdToken.fromMap({ + 'sub': 'test-uid-123', + 'uid': 'test-uid-123', + 'aud': 'test-project', + 'iss': 'https://securetoken.google.com/test-project', + 'iat': DateTime.now().millisecondsSinceEpoch ~/ 1000, + 'exp': + DateTime.now() + .add(const Duration(hours: 1)) + .millisecondsSinceEpoch ~/ + 1000, + 'auth_time': DateTime.now().millisecondsSinceEpoch ~/ 1000, + 'firebase': { + 'identities': {}, + 'sign_in_provider': 'custom', + 'tenant': tenantId, + }, + }); + + when( + () => mockIdTokenVerifier.verifyJWT( + any(), + isEmulator: any(named: 'isEmulator'), + ), + ).thenAnswer((_) async => decodedIdToken); + + final app = _createMockApp(); + final tenantAuth = TenantAwareAuth.internal( + app, + tenantId, + idTokenVerifier: mockIdTokenVerifier, + ); + + expect( + () => tenantAuth.createSessionCookie( + 'id-token', + const SessionCookieOptions( + expiresIn: 15 * 24 * 60 * 60 * 1000, // 15 days - too long + ), + ), + throwsA(isA()), + ); + }); + }); + + group('verifySessionCookie', () { + test( + 'verifies session cookie successfully with matching tenant ID', + () async { + const tenantId = 'test-tenant-id'; + final mockSessionCookieVerifier = MockFirebaseTokenVerifier(); + final decodedToken = DecodedIdToken.fromMap({ + 'sub': 'test-uid-123', + 'uid': 'test-uid-123', + 'aud': 'test-project', + 'iss': 'https://session.firebase.google.com/test-project', + 'iat': DateTime.now().millisecondsSinceEpoch ~/ 1000, + 'exp': + DateTime.now() + .add(const Duration(hours: 1)) + .millisecondsSinceEpoch ~/ + 1000, + 'auth_time': DateTime.now().millisecondsSinceEpoch ~/ 1000, + 'firebase': { + 'identities': {}, + 'sign_in_provider': 'custom', + 'tenant': tenantId, + }, + }); + + when( + () => mockSessionCookieVerifier.verifyJWT( + any(), + isEmulator: any(named: 'isEmulator'), + ), + ).thenAnswer((_) async => decodedToken); + + // Always mock HTTP client for getUser calls + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'users': [ + { + 'localId': 'test-uid-123', + 'email': 'test@example.com', + 'disabled': false, + 'createdAt': '1234567890000', + }, + ], + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-verify-session-cookie', + ); + final tenantAuth = TenantAwareAuth.internal( + app, + tenantId, + sessionCookieVerifier: mockSessionCookieVerifier, + ); + + final result = await tenantAuth.verifySessionCookie( + 'mock-session-cookie', + ); + + expect(result.uid, equals('test-uid-123')); + expect(result.sub, equals('test-uid-123')); + verify( + () => mockSessionCookieVerifier.verifyJWT( + 'mock-session-cookie', + isEmulator: any(named: 'isEmulator'), + ), + ).called(1); + }, + ); + + test('throws when session cookie has mismatching tenant ID', () async { + const tenantId = 'test-tenant-id'; + const wrongTenantId = 'wrong-tenant-id'; + final mockSessionCookieVerifier = MockFirebaseTokenVerifier(); + final decodedToken = DecodedIdToken.fromMap({ + 'sub': 'test-uid-123', + 'uid': 'test-uid-123', + 'aud': 'test-project', + 'iss': 'https://session.firebase.google.com/test-project', + 'iat': DateTime.now().millisecondsSinceEpoch ~/ 1000, + 'exp': + DateTime.now() + .add(const Duration(hours: 1)) + .millisecondsSinceEpoch ~/ + 1000, + 'auth_time': DateTime.now().millisecondsSinceEpoch ~/ 1000, + 'firebase': { + 'identities': {}, + 'sign_in_provider': 'custom', + 'tenant': wrongTenantId, + }, + }); + + when( + () => mockSessionCookieVerifier.verifyJWT( + any(), + isEmulator: any(named: 'isEmulator'), + ), + ).thenAnswer((_) async => decodedToken); + + // Mock HTTP client for getUser calls (needed when emulator is enabled or checkRevoked is true) + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'users': [ + { + 'localId': 'test-uid-123', + 'email': 'test@example.com', + 'disabled': false, + 'createdAt': '1234567890000', + }, + ], + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-mismatching-tenant', + ); + final tenantAuth = TenantAwareAuth.internal( + app, + tenantId, + sessionCookieVerifier: mockSessionCookieVerifier, + ); + + await expectLater( + () => tenantAuth.verifySessionCookie('mock-session-cookie'), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/mismatching-tenant-id', + ), + ), + ); + }); + + test('throws when sessionCookie is empty', () async { + const tenantId = 'test-tenant-id'; + final mockSessionCookieVerifier = MockFirebaseTokenVerifier(); + when( + () => mockSessionCookieVerifier.verifyJWT( + any(), + isEmulator: any(named: 'isEmulator'), + ), + ).thenThrow( + FirebaseAuthAdminException( + AuthClientErrorCode.invalidArgument, + 'Firebase session cookie has invalid format.', + ), + ); + + final app = createApp(name: 'test-empty-session-cookie'); + final tenantAuth = TenantAwareAuth.internal( + app, + tenantId, + sessionCookieVerifier: mockSessionCookieVerifier, + ); + + await expectLater( + () => tenantAuth.verifySessionCookie(''), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/argument-error', + ), + ), + ); + }); + + test('throws when sessionCookie is invalid', () async { + const tenantId = 'test-tenant-id'; + final mockSessionCookieVerifier = MockFirebaseTokenVerifier(); + when( + () => mockSessionCookieVerifier.verifyJWT( + any(), + isEmulator: any(named: 'isEmulator'), + ), + ).thenThrow( + FirebaseAuthAdminException( + AuthClientErrorCode.invalidArgument, + 'Decoding Firebase session cookie failed.', + ), + ); + + final app = createApp(name: 'test-invalid-session-cookie'); + final tenantAuth = TenantAwareAuth.internal( + app, + tenantId, + sessionCookieVerifier: mockSessionCookieVerifier, + ); + + await expectLater( + () => tenantAuth.verifySessionCookie('invalid-cookie'), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/argument-error', + ), + ), + ); + }); + + test('throws when checkRevoked is true and user is disabled', () async { + const tenantId = 'test-tenant-id'; + final mockSessionCookieVerifier = MockFirebaseTokenVerifier(); + final decodedToken = DecodedIdToken.fromMap({ + 'sub': 'test-uid-123', + 'uid': 'test-uid-123', + 'aud': 'test-project', + 'iss': 'https://session.firebase.google.com/test-project', + 'iat': DateTime.now().millisecondsSinceEpoch ~/ 1000, + 'exp': + DateTime.now() + .add(const Duration(hours: 1)) + .millisecondsSinceEpoch ~/ + 1000, + 'auth_time': DateTime.now().millisecondsSinceEpoch ~/ 1000, + 'firebase': { + 'identities': {}, + 'sign_in_provider': 'custom', + 'tenant': tenantId, + }, + }); + + when( + () => mockSessionCookieVerifier.verifyJWT( + any(), + isEmulator: any(named: 'isEmulator'), + ), + ).thenAnswer((_) async => decodedToken); + + final clientMock = ClientMock(); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'users': [ + { + 'localId': 'test-uid-123', + 'email': 'test@example.com', + 'disabled': true, + 'createdAt': '1234567890000', + }, + ], + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp(client: clientMock, name: 'test-user-disabled'); + final tenantAuth = TenantAwareAuth.internal( + app, + tenantId, + sessionCookieVerifier: mockSessionCookieVerifier, + ); + + await expectLater( + () => + tenantAuth.verifySessionCookie('mock-cookie', checkRevoked: true), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'auth/user-disabled', + ), + ), + ); + }); + + test( + 'succeeds when checkRevoked is true and cookie is not revoked', + () async { + const tenantId = 'test-tenant-id'; + final mockSessionCookieVerifier = MockFirebaseTokenVerifier(); + final authTime = DateTime.now().subtract(const Duration(minutes: 30)); + final decodedToken = DecodedIdToken.fromMap({ + 'sub': 'test-uid-123', + 'uid': 'test-uid-123', + 'aud': 'test-project', + 'iss': 'https://session.firebase.google.com/test-project', + 'iat': DateTime.now().millisecondsSinceEpoch ~/ 1000, + 'exp': + DateTime.now() + .add(const Duration(hours: 1)) + .millisecondsSinceEpoch ~/ + 1000, + 'auth_time': authTime.millisecondsSinceEpoch ~/ 1000, + 'firebase': { + 'identities': {}, + 'sign_in_provider': 'custom', + 'tenant': tenantId, + }, + }); + + when( + () => mockSessionCookieVerifier.verifyJWT( + any(), + isEmulator: any(named: 'isEmulator'), + ), + ).thenAnswer((_) async => decodedToken); + + final clientMock = ClientMock(); + // validSince is before auth_time, so cookie is not revoked + final validSince = DateTime.now().subtract(const Duration(hours: 2)); + when(() => clientMock.send(any())).thenAnswer( + (_) => Future.value( + StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'users': [ + { + 'localId': 'test-uid-123', + 'email': 'test@example.com', + 'disabled': false, + 'validSince': + (validSince.millisecondsSinceEpoch ~/ 1000) + .toString(), + 'createdAt': '1234567890000', + }, + ], + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ); + + final app = createApp( + client: clientMock, + name: 'test-cookie-not-revoked', + ); + final tenantAuth = TenantAwareAuth.internal( + app, + tenantId, + sessionCookieVerifier: mockSessionCookieVerifier, + ); + + final result = await tenantAuth.verifySessionCookie( + 'mock-cookie', + checkRevoked: true, + ); + + expect(result.uid, equals('test-uid-123')); + }, + ); + }); }); group('UpdateTenantRequest', () { @@ -179,6 +1531,172 @@ void main() { expect(request.displayName, equals('New Tenant')); }); }); + + group('Tenant.toJson', () { + test('toJson serialization with minimal fields', () async { + final app = _createMockApp(); + final mockRequestHandler = AuthRequestHandlerMock(); + final tenantManager = TenantManager.internal( + app, + authRequestHandler: mockRequestHandler, + ); + + final tenantResponse = { + 'name': 'projects/test-project/tenants/minimal-tenant', + 'allowPasswordSignup': false, + 'enableEmailLinkSignin': false, + 'enableAnonymousUser': false, + }; + + when( + () => mockRequestHandler.getTenant(any()), + ).thenAnswer((_) async => tenantResponse); + + final tenant = await tenantManager.getTenant('minimal-tenant'); + final json = tenant.toJson(); + + expect(json['tenantId'], equals('minimal-tenant')); + expect(json['anonymousSignInEnabled'], equals(false)); + expect(json.containsKey('displayName'), isFalse); + expect(json.containsKey('emailSignInConfig'), isTrue); + expect(json.containsKey('multiFactorConfig'), isFalse); + expect(json.containsKey('testPhoneNumbers'), isFalse); + expect(json.containsKey('smsRegionConfig'), isFalse); + expect(json.containsKey('recaptchaConfig'), isFalse); + expect(json.containsKey('passwordPolicyConfig'), isFalse); + expect(json.containsKey('emailPrivacyConfig'), isFalse); + }); + + test('toJson serialization with all fields', () async { + final app = _createMockApp(); + final mockRequestHandler = AuthRequestHandlerMock(); + final tenantManager = TenantManager.internal( + app, + authRequestHandler: mockRequestHandler, + ); + + final tenantResponse = { + 'name': 'projects/test-project/tenants/full-tenant', + 'displayName': 'Full Config Tenant', + 'allowPasswordSignup': true, + 'enableEmailLinkSignin': true, + 'enableAnonymousUser': true, + 'testPhoneNumbers': { + '+11234567890': '123456', + '+19876543210': '654321', + }, + 'smsRegionConfig': { + 'allowByDefault': { + 'disallowedRegions': ['US', 'CA'], + }, + }, + 'mfaConfig': { + 'state': 'ENABLED', + 'enabledProviders': [ + {'state': 'ENABLED', 'totpProviderConfig': {}}, + ], + }, + 'recaptchaConfig': { + 'emailPasswordEnforcementState': 'ENFORCE', + 'phoneEnforcementState': 'AUDIT', + }, + 'passwordPolicyConfig': {'passwordPolicyEnforcementState': 'ENFORCE'}, + 'emailPrivacyConfig': {'enableImprovedEmailPrivacy': true}, + }; + + when( + () => mockRequestHandler.getTenant(any()), + ).thenAnswer((_) async => tenantResponse); + + final tenant = await tenantManager.getTenant('full-tenant'); + final json = tenant.toJson(); + + expect(json['tenantId'], equals('full-tenant')); + expect(json['displayName'], equals('Full Config Tenant')); + expect(json['anonymousSignInEnabled'], equals(true)); + expect(json.containsKey('testPhoneNumbers'), isTrue); + expect(json['testPhoneNumbers'], isA>()); + expect(json.containsKey('emailSignInConfig'), isTrue); + expect(json.containsKey('multiFactorConfig'), isTrue); + expect(json.containsKey('smsRegionConfig'), isTrue); + expect(json.containsKey('recaptchaConfig'), isTrue); + expect(json.containsKey('passwordPolicyConfig'), isTrue); + expect(json.containsKey('emailPrivacyConfig'), isTrue); + }); + + test('toJson excludes null optional properties', () async { + final app = _createMockApp(); + final mockRequestHandler = AuthRequestHandlerMock(); + final tenantManager = TenantManager.internal( + app, + authRequestHandler: mockRequestHandler, + ); + + final tenantResponse = { + 'name': 'projects/test-project/tenants/partial-tenant', + 'displayName': 'Partial Tenant', + 'allowPasswordSignup': true, + 'enableEmailLinkSignin': false, + 'enableAnonymousUser': false, + 'smsRegionConfig': { + 'allowByDefault': { + 'disallowedRegions': ['US'], + }, + }, + }; + + when( + () => mockRequestHandler.getTenant(any()), + ).thenAnswer((_) async => tenantResponse); + + final tenant = await tenantManager.getTenant('partial-tenant'); + final json = tenant.toJson(); + + expect(json['tenantId'], equals('partial-tenant')); + expect(json['displayName'], equals('Partial Tenant')); + expect(json['anonymousSignInEnabled'], equals(false)); + expect(json.containsKey('testPhoneNumbers'), isFalse); + expect(json.containsKey('emailSignInConfig'), isTrue); + expect(json.containsKey('multiFactorConfig'), isFalse); + expect(json.containsKey('smsRegionConfig'), isTrue); + expect(json.containsKey('recaptchaConfig'), isFalse); + expect(json.containsKey('passwordPolicyConfig'), isFalse); + expect(json.containsKey('emailPrivacyConfig'), isFalse); + }); + + test('toJson handles allowlistOnly SMS region config', () async { + final app = _createMockApp(); + final mockRequestHandler = AuthRequestHandlerMock(); + final tenantManager = TenantManager.internal( + app, + authRequestHandler: mockRequestHandler, + ); + + final tenantResponse = { + 'name': 'projects/test-project/tenants/allowlist-tenant', + 'allowPasswordSignup': false, + 'enableEmailLinkSignin': false, + 'enableAnonymousUser': false, + 'smsRegionConfig': { + 'allowlistOnly': { + 'allowedRegions': ['GB', 'FR', 'DE'], + }, + }, + }; + + when( + () => mockRequestHandler.getTenant(any()), + ).thenAnswer((_) async => tenantResponse); + + final tenant = await tenantManager.getTenant('allowlist-tenant'); + final json = tenant.toJson(); + + expect(json['tenantId'], equals('allowlist-tenant')); + expect(json.containsKey('smsRegionConfig'), isTrue); + final smsConfig = json['smsRegionConfig'] as Map; + expect(smsConfig.containsKey('allowlistOnly'), isTrue); + }); + }); } // Mock app for testing diff --git a/packages/dart_firebase_admin/test/auth/user_test.dart b/packages/dart_firebase_admin/test/auth/user_test.dart index d3d93285..67ea39cb 100644 --- a/packages/dart_firebase_admin/test/auth/user_test.dart +++ b/packages/dart_firebase_admin/test/auth/user_test.dart @@ -190,6 +190,146 @@ void main() { }); }); + group('TotpInfo', () { + test('can be instantiated', () { + final totpInfo = TotpInfo(); + expect(totpInfo, isNotNull); + expect(totpInfo, isA()); + }); + }); + + group('TotpMultiFactorInfo', () { + test('fromResponse with all fields', () { + final mfaInfo = TotpMultiFactorInfo.fromResponse( + auth1.GoogleCloudIdentitytoolkitV1MfaEnrollment( + mfaEnrollmentId: 'totp-123', + displayName: 'My Authenticator', + totpInfo: auth1.GoogleCloudIdentitytoolkitV1TotpInfo(), + enrolledAt: '1234567890000', + ), + ); + + expect(mfaInfo.uid, 'totp-123'); + expect(mfaInfo.displayName, 'My Authenticator'); + expect(mfaInfo.totpInfo, isA()); + expect(mfaInfo.factorId, MultiFactorId.totp); + expect( + mfaInfo.enrollmentTime, + DateTime.fromMillisecondsSinceEpoch(1234567890000), + ); + }); + + test('fromResponse with minimal fields', () { + final mfaInfo = TotpMultiFactorInfo.fromResponse( + auth1.GoogleCloudIdentitytoolkitV1MfaEnrollment( + mfaEnrollmentId: 'totp-456', + totpInfo: auth1.GoogleCloudIdentitytoolkitV1TotpInfo(), + enrolledAt: '1000000000000', + ), + ); + + expect(mfaInfo.uid, 'totp-456'); + expect(mfaInfo.displayName, isNull); + expect(mfaInfo.totpInfo, isNotNull); + expect(mfaInfo.factorId, MultiFactorId.totp); + }); + + test('fromResponse throws when mfaEnrollmentId is missing', () { + expect( + () => TotpMultiFactorInfo.fromResponse( + auth1.GoogleCloudIdentitytoolkitV1MfaEnrollment( + totpInfo: auth1.GoogleCloudIdentitytoolkitV1TotpInfo(), + ), + ), + throwsA(isA()), + ); + }); + + test('toJson includes totpInfo', () { + final mfaInfo = TotpMultiFactorInfo.fromResponse( + auth1.GoogleCloudIdentitytoolkitV1MfaEnrollment( + mfaEnrollmentId: 'totp-789', + displayName: 'Work Authenticator', + totpInfo: auth1.GoogleCloudIdentitytoolkitV1TotpInfo(), + enrolledAt: '2000000000000', + ), + ); + + final json = mfaInfo.toJson(); + expect(json['uid'], 'totp-789'); + expect(json['displayName'], 'Work Authenticator'); + expect(json['totpInfo'], isA>()); + expect(json['factorId'], 'totp'); + expect(json['enrollmentTime'], isNotNull); + }); + }); + + group('MultiFactorInfo.initMultiFactorInfo', () { + test('returns PhoneMultiFactorInfo when phoneInfo is present', () { + final mfaInfo = MultiFactorInfo.initMultiFactorInfo( + auth1.GoogleCloudIdentitytoolkitV1MfaEnrollment( + mfaEnrollmentId: 'phone-1', + phoneInfo: '+15555555555', + enrolledAt: '1000', + ), + ); + + expect(mfaInfo, isA()); + expect(mfaInfo?.factorId, MultiFactorId.phone); + }); + + test('returns TotpMultiFactorInfo when totpInfo is present', () { + final mfaInfo = MultiFactorInfo.initMultiFactorInfo( + auth1.GoogleCloudIdentitytoolkitV1MfaEnrollment( + mfaEnrollmentId: 'totp-1', + totpInfo: auth1.GoogleCloudIdentitytoolkitV1TotpInfo(), + enrolledAt: '1000', + ), + ); + + expect(mfaInfo, isA()); + expect(mfaInfo?.factorId, MultiFactorId.totp); + }); + + test('prefers phoneInfo over totpInfo when both are present', () { + final mfaInfo = MultiFactorInfo.initMultiFactorInfo( + auth1.GoogleCloudIdentitytoolkitV1MfaEnrollment( + mfaEnrollmentId: 'both-1', + phoneInfo: '+15555555555', + totpInfo: auth1.GoogleCloudIdentitytoolkitV1TotpInfo(), + enrolledAt: '1000', + ), + ); + + expect(mfaInfo, isA()); + expect(mfaInfo?.factorId, MultiFactorId.phone); + }); + + test('returns null when neither phoneInfo nor totpInfo is present', () { + final mfaInfo = MultiFactorInfo.initMultiFactorInfo( + auth1.GoogleCloudIdentitytoolkitV1MfaEnrollment( + mfaEnrollmentId: 'unknown-1', + enrolledAt: '1000', + ), + ); + + expect(mfaInfo, isNull); + }); + + test('returns null and ignores errors', () { + // Test that errors are caught and null is returned + final mfaInfo = MultiFactorInfo.initMultiFactorInfo( + auth1.GoogleCloudIdentitytoolkitV1MfaEnrollment( + // Missing mfaEnrollmentId will cause error + phoneInfo: '+15555555555', + enrolledAt: '1000', + ), + ); + + expect(mfaInfo, isNull); + }); + }); + group('MultiFactorSettings', () { test('fromResponse with enrolled factors', () { final settings = MultiFactorSettings.fromResponse( @@ -249,6 +389,85 @@ void main() { final enrolledFactors = json['enrolledFactors']! as List; expect((enrolledFactors[0] as Map)['uid'], 'mfa-1'); }); + + test('fromResponse with TOTP enrolled factors', () { + final settings = MultiFactorSettings.fromResponse( + auth1.GoogleCloudIdentitytoolkitV1UserInfo( + mfaInfo: [ + auth1.GoogleCloudIdentitytoolkitV1MfaEnrollment( + mfaEnrollmentId: 'totp-factor-1', + totpInfo: auth1.GoogleCloudIdentitytoolkitV1TotpInfo(), + displayName: 'Google Authenticator', + enrolledAt: '1000', + ), + auth1.GoogleCloudIdentitytoolkitV1MfaEnrollment( + mfaEnrollmentId: 'totp-factor-2', + totpInfo: auth1.GoogleCloudIdentitytoolkitV1TotpInfo(), + displayName: 'Authy', + enrolledAt: '2000', + ), + ], + ), + ); + + expect(settings.enrolledFactors, hasLength(2)); + expect(settings.enrolledFactors[0], isA()); + expect(settings.enrolledFactors[0].uid, 'totp-factor-1'); + expect(settings.enrolledFactors[1], isA()); + expect(settings.enrolledFactors[1].uid, 'totp-factor-2'); + }); + + test('fromResponse with mixed phone and TOTP factors', () { + final settings = MultiFactorSettings.fromResponse( + auth1.GoogleCloudIdentitytoolkitV1UserInfo( + mfaInfo: [ + auth1.GoogleCloudIdentitytoolkitV1MfaEnrollment( + mfaEnrollmentId: 'phone-1', + phoneInfo: '+15555555555', + enrolledAt: '1000', + ), + auth1.GoogleCloudIdentitytoolkitV1MfaEnrollment( + mfaEnrollmentId: 'totp-1', + totpInfo: auth1.GoogleCloudIdentitytoolkitV1TotpInfo(), + enrolledAt: '2000', + ), + auth1.GoogleCloudIdentitytoolkitV1MfaEnrollment( + mfaEnrollmentId: 'phone-2', + phoneInfo: '+16666666666', + enrolledAt: '3000', + ), + ], + ), + ); + + expect(settings.enrolledFactors, hasLength(3)); + expect(settings.enrolledFactors[0], isA()); + expect(settings.enrolledFactors[1], isA()); + expect(settings.enrolledFactors[2], isA()); + }); + + test('toJson with TOTP factors', () { + final settings = MultiFactorSettings.fromResponse( + auth1.GoogleCloudIdentitytoolkitV1UserInfo( + mfaInfo: [ + auth1.GoogleCloudIdentitytoolkitV1MfaEnrollment( + mfaEnrollmentId: 'totp-test', + totpInfo: auth1.GoogleCloudIdentitytoolkitV1TotpInfo(), + displayName: 'My App', + enrolledAt: '9999', + ), + ], + ), + ); + + final json = settings.toJson(); + final enrolledFactors = json['enrolledFactors']! as List; + final totpFactor = enrolledFactors[0] as Map; + expect(totpFactor['uid'], 'totp-test'); + expect(totpFactor['factorId'], 'totp'); + expect(totpFactor['displayName'], 'My App'); + expect(totpFactor['totpInfo'], isA>()); + }); }); group('UserRecord', () { diff --git a/packages/dart_firebase_admin/test/auth/util/helpers.dart b/packages/dart_firebase_admin/test/auth/util/helpers.dart new file mode 100644 index 00000000..09ba4b47 --- /dev/null +++ b/packages/dart_firebase_admin/test/auth/util/helpers.dart @@ -0,0 +1,75 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:dart_firebase_admin/auth.dart'; +import 'package:dart_firebase_admin/src/app.dart'; +import 'package:test/test.dart'; + +import '../../google_cloud_firestore/util/helpers.dart'; + +Future cleanup(Auth auth) async { + // Only cleanup if we're using the emulator + // Mock clients used in error handling tests won't have the emulator enabled + if (!Environment.isAuthEmulatorEnabled()) { + return; // Skip cleanup for non-emulator tests + } + + try { + final users = await auth.listUsers(); + await Future.wait([ + for (final user in users.users) auth.deleteUser(user.uid), + ]); + } catch (e) { + // Ignore cleanup errors - they're not critical for test execution + } +} + +/// Creates an Auth instance for testing. +/// +/// Automatically cleans up all users after each test. +/// +/// By default, requires Firebase Auth Emulator to prevent accidental writes to production. +/// For tests that require production (e.g., session cookies with GCIP), set [requireEmulator] to false. +/// +/// Note: Tests should be run with FIREBASE_AUTH_EMULATOR_HOST=localhost:9099 +/// environment variable set. The emulator will be auto-detected. +Auth createAuthForTest({bool requireEmulator = true}) { + // CRITICAL: Ensure emulator is running to prevent hitting production + // unless explicitly disabled for production-only tests + if (requireEmulator && !Environment.isAuthEmulatorEnabled()) { + throw StateError( + '${Environment.firebaseAuthEmulatorHost} environment variable must be set to run tests. ' + 'This prevents accidentally writing test data to production. ' + 'Set it to "localhost:9099" or your emulator host.\n\n' + 'For production-only tests, use createAuthForTest(requireEmulator: false)', + ); + } + + late Auth auth; + late FirebaseApp app; + + // Remove production credentials from zone environment to force emulator usage + // This prevents accidentally hitting production when both emulator and credentials are set + final emulatorEnv = Map.from(Platform.environment); + emulatorEnv.remove(Environment.googleApplicationCredentials); + + runZoned(() { + // Use unique app name for each test to avoid interference + final appName = 'auth-test-${DateTime.now().microsecondsSinceEpoch}'; + + app = createApp( + name: appName, + tearDown: () async { + // Cleanup will be handled by addTearDown below + }, + ); + + auth = Auth(app); + + addTearDown(() async { + await cleanup(auth); + }); + }, zoneValues: {envSymbol: emulatorEnv}); + + return auth; +} diff --git a/packages/dart_firebase_admin/test/credential_test.dart b/packages/dart_firebase_admin/test/credential_test.dart index 1337a369..5de88735 100644 --- a/packages/dart_firebase_admin/test/credential_test.dart +++ b/packages/dart_firebase_admin/test/credential_test.dart @@ -11,6 +11,7 @@ import 'mock_service_account.dart'; const _fakeRSAKey = '-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCUD3KKtJk6JEDA\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n4h3z8UdjAgMBAAECggEAR5HmBO2CygufLxLzbZ/jwN7Yitf0v/nT8LRjDs1WFux9\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\nPPZaRPjBWvdqg4QttSSBKGm5FnhFPrpEFvOjznNBoQKBgQDJpRvDTIkNnpYhi/ni\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\ndLSYULRW1DBgakQd09NRvPBoQwKBgQC7+KGhoXw5Kvr7qnQu+x0Gb+8u8CHT0qCG\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\nvpTRZN3CYQKBgFBc/DaWnxyNcpoGFl4lkBy/G9Q2hPf5KRsqS0CDL7BXCpL0lCyz\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\nOcltaAFaTptzmARfj0Q2d7eEzemABr9JHdyCdY0RXgJe96zHijXOTiXPAoGAfe+C\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\npEmuauUytUaZ16G8/T8qh/ndPcqslwHQqsmtWYECgYEAwpvpZvvh7LXH5/OeLRjs\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\nKhg2WH+bggdnYug+oRFauQs=\n-----END PRIVATE KEY-----'; +// TODO(demolaf): check if we have sufficient tests for credential void main() { group(Credential, () { test('fromServiceAccountParams', () { diff --git a/packages/dart_firebase_admin/test/google_cloud_firestore/document_test.dart b/packages/dart_firebase_admin/test/google_cloud_firestore/document_test.dart index 033779f9..1f1e5d08 100644 --- a/packages/dart_firebase_admin/test/google_cloud_firestore/document_test.dart +++ b/packages/dart_firebase_admin/test/google_cloud_firestore/document_test.dart @@ -142,25 +142,31 @@ void main() { expect(data, {'😀': '😜'}); }); - test('Supports NaN and Infinity', skip: true, () async { - // This fails because GRPC uses dart:convert.json.encode which does not support NaN or Infinity - await firestore.doc('collectionId/nan').set({ - 'nan': double.nan, - 'infinity': double.infinity, - 'negativeInfinity': double.negativeInfinity, - }); + test( + 'Supports NaN and Infinity', + () async { + // + await firestore.doc('collectionId/nan').set({ + 'nan': double.nan, + 'infinity': double.infinity, + 'negativeInfinity': double.negativeInfinity, + }); - final data = await firestore - .doc('collectionId/nan') - .get() - .then((snapshot) => snapshot.data()); + final data = await firestore + .doc('collectionId/nan') + .get() + .then((snapshot) => snapshot.data()); - expect(data, { - 'nan': double.nan, - 'infinity': double.infinity, - 'negativeInfinity': double.negativeInfinity, - }); - }); + expect(data, { + 'nan': double.nan, + 'infinity': double.infinity, + 'negativeInfinity': double.negativeInfinity, + }); + }, + skip: + 'This fails because GRPC uses dart:convert.json.encode which does ' + 'not support NaN or Infinity', + ); test('with invalid geopoint', () { expect( diff --git a/packages/dart_firebase_admin/test/google_cloud_firestore/util/helpers.dart b/packages/dart_firebase_admin/test/google_cloud_firestore/util/helpers.dart index c74c143a..e425fa78 100644 --- a/packages/dart_firebase_admin/test/google_cloud_firestore/util/helpers.dart +++ b/packages/dart_firebase_admin/test/google_cloud_firestore/util/helpers.dart @@ -12,7 +12,7 @@ const projectId = 'dart-firebase-admin'; /// Whether Google Application Default Credentials are available. /// Used to skip tests that require production Firebase access. final hasGoogleEnv = - Platform.environment['GOOGLE_APPLICATION_CREDENTIALS'] != null; + Platform.environment[Environment.googleApplicationCredentials] != null; /// Validates that required emulator environment variables are set. /// diff --git a/packages/dart_firebase_admin/test/messaging/messaging_test.dart b/packages/dart_firebase_admin/test/messaging/messaging_test.dart index 9b4d2363..824b6a04 100644 --- a/packages/dart_firebase_admin/test/messaging/messaging_test.dart +++ b/packages/dart_firebase_admin/test/messaging/messaging_test.dart @@ -64,7 +64,7 @@ void main() { app, httpClient: httpClient, ); - messaging = Messaging(app, requestHandler: requestHandler); + messaging = Messaging.internal(app, requestHandler: requestHandler); }); tearDown(() { diff --git a/packages/dart_firebase_admin/test/mock.dart b/packages/dart_firebase_admin/test/mock.dart index fbac8d00..dc3787f9 100644 --- a/packages/dart_firebase_admin/test/mock.dart +++ b/packages/dart_firebase_admin/test/mock.dart @@ -19,4 +19,6 @@ class AuthRequestHandlerMock extends Mock implements AuthRequestHandler {} class AuthHttpClientMock extends Mock implements AuthHttpClient {} +class MockFirebaseTokenVerifier extends Mock implements FirebaseTokenVerifier {} + class _SendMessageRequestFake extends Fake implements SendMessageRequest {} diff --git a/scripts/coverage.sh b/scripts/coverage.sh index 6d13bd86..b48a4710 100755 --- a/scripts/coverage.sh +++ b/scripts/coverage.sh @@ -3,6 +3,12 @@ # Fast fail the script on failures. set -e +# Uncomment these to run prod tests locally, CI doesn't have service-account-key.json +# (service account credentials) only application default credentials and uses gcloud auth login. +# export FIRESTORE_EMULATOR_HOST=localhost:8080 +# export FIREBASE_AUTH_EMULATOR_HOST=localhost:9099 +# export GOOGLE_APPLICATION_CREDENTIALS=service-account-key.json + # Get the script's directory and the package directory SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" PACKAGE_DIR="$SCRIPT_DIR/../packages/dart_firebase_admin"