Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
fix(auth, ios): fix inconsistence in casing in the native iOS SDK wit…
…h a workaround
  • Loading branch information
Lyokone committed Mar 6, 2026
commit fbaeaa3c4c81d64a09101743474cf387fd8f3128
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ @implementation FLTFirebaseAuthPlugin {
// Map an id to a MultiFactorResolver object.
NSMutableDictionary<NSString *, FIRTOTPSecret *> *_multiFactorTotpSecretMap;

// Emulator host/port per app, used to build REST URLs for workarounds.
NSMutableDictionary<NSString *, NSDictionary *> *_emulatorConfigs;

NSObject<FlutterBinaryMessenger> *_binaryMessenger;
NSMutableDictionary<NSString *, FlutterEventChannel *> *_eventChannels;
NSMutableDictionary<NSString *, NSObject<FlutterStreamHandler> *> *_streamHandlers;
Expand All @@ -134,6 +137,7 @@ - (instancetype)init:(NSObject<FlutterBinaryMessenger> *)messenger {
_multiFactorResolverMap = [NSMutableDictionary dictionary];
_multiFactorAssertionMap = [NSMutableDictionary dictionary];
_multiFactorTotpSecretMap = [NSMutableDictionary dictionary];
_emulatorConfigs = [NSMutableDictionary dictionary];
}
return self;
}
Expand Down Expand Up @@ -1137,7 +1141,20 @@ - (void)checkActionCodeApp:(nonnull AuthPigeonFirebaseApp *)app
if (error != nil) {
completion(nil, [FLTFirebaseAuthPlugin convertToFlutterError:error]);
} else {
completion([self parseActionCode:info], nil);
PigeonActionCodeInfo *result = [self parseActionCode:info];
if (result.operation == ActionCodeInfoOperationUnknown) {
// Workaround: Firebase iOS SDK >=11.12.0 returns .unknown because
// actionCodeOperation(forRequestType:) only matches camelCase but the
// REST API returns SCREAMING_SNAKE_CASE (e.g. "VERIFY_EMAIL").
// Re-fetch the raw requestType via REST to resolve the operation.
// See: https://github.com/firebase/flutterfire/issues/17452
[self resolveActionCodeOperationForApp:app
code:code
fallbackInfo:result
completion:completion];
} else {
completion(result, nil);
}
}
}];
}
Expand Down Expand Up @@ -1167,6 +1184,91 @@ - (PigeonActionCodeInfo *_Nullable)parseActionCode:(nonnull FIRActionCodeInfo *)
return [PigeonActionCodeInfo makeWithOperation:operation data:data];
}

/// Maps a raw requestType string (either camelCase or SCREAMING_SNAKE_CASE) to
/// the corresponding Pigeon enum value.
+ (ActionCodeInfoOperation)operationFromRequestType:(nullable NSString *)requestType {
static NSDictionary<NSString *, NSNumber *> *mapping;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
mapping = @{
@"PASSWORD_RESET" : @(ActionCodeInfoOperationPasswordReset),
@"resetPassword" : @(ActionCodeInfoOperationPasswordReset),
@"VERIFY_EMAIL" : @(ActionCodeInfoOperationVerifyEmail),
@"verifyEmail" : @(ActionCodeInfoOperationVerifyEmail),
@"RECOVER_EMAIL" : @(ActionCodeInfoOperationRecoverEmail),
@"recoverEmail" : @(ActionCodeInfoOperationRecoverEmail),
@"EMAIL_SIGNIN" : @(ActionCodeInfoOperationEmailSignIn),
@"signIn" : @(ActionCodeInfoOperationEmailSignIn),
@"VERIFY_AND_CHANGE_EMAIL" : @(ActionCodeInfoOperationVerifyAndChangeEmail),
@"verifyAndChangeEmail" : @(ActionCodeInfoOperationVerifyAndChangeEmail),
@"REVERT_SECOND_FACTOR_ADDITION" : @(ActionCodeInfoOperationRevertSecondFactorAddition),
@"revertSecondFactorAddition" : @(ActionCodeInfoOperationRevertSecondFactorAddition),
};
});

NSNumber *value = mapping[requestType];
return value ? (ActionCodeInfoOperation)value.integerValue : ActionCodeInfoOperationUnknown;
}

/// Calls the Identity Toolkit REST API directly to retrieve the raw requestType
/// string, which the iOS SDK fails to parse correctly. Falls back to the original
/// result if the REST call fails for any reason.
- (void)resolveActionCodeOperationForApp:(nonnull AuthPigeonFirebaseApp *)app
code:(nonnull NSString *)code
fallbackInfo:(nonnull PigeonActionCodeInfo *)fallbackInfo
completion:(nonnull void (^)(PigeonActionCodeInfo *_Nullable,
FlutterError *_Nullable))completion {
FIRApp *firebaseApp = [FLTFirebasePlugin firebaseAppNamed:app.appName];
NSString *apiKey = firebaseApp.options.APIKey;

NSString *baseURL;
NSDictionary *emulatorConfig = _emulatorConfigs[app.appName];
if (emulatorConfig) {
baseURL = [NSString stringWithFormat:@"http://%@:%@/identitytoolkit.googleapis.com",
emulatorConfig[@"host"], emulatorConfig[@"port"]];
} else {
baseURL = @"https://identitytoolkit.googleapis.com";
}

NSString *urlString =
[NSString stringWithFormat:@"%@/v1/accounts:resetPassword?key=%@", baseURL, apiKey];
NSURL *url = [NSURL URLWithString:urlString];

NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];
request.HTTPMethod = @"POST";
[request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"];
request.HTTPBody = [NSJSONSerialization dataWithJSONObject:@{@"oobCode" : code}
options:0
error:nil];

NSURLSessionDataTask *task = [[NSURLSession sharedSession]
dataTaskWithRequest:request
completionHandler:^(NSData *_Nullable data, NSURLResponse *_Nullable response,
NSError *_Nullable error) {
if (error || !data) {
completion(fallbackInfo, nil);
return;
}

NSDictionary *json = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
if (!json || json[@"error"]) {
completion(fallbackInfo, nil);
return;
}

ActionCodeInfoOperation operation =
[FLTFirebaseAuthPlugin operationFromRequestType:json[@"requestType"]];

if (operation != ActionCodeInfoOperationUnknown) {
completion([PigeonActionCodeInfo makeWithOperation:operation data:fallbackInfo.data],
nil);
} else {
completion(fallbackInfo, nil);
}
}];
[task resume];
}

- (void)confirmPasswordResetApp:(nonnull AuthPigeonFirebaseApp *)app
code:(nonnull NSString *)code
newPassword:(nonnull NSString *)newPassword
Expand Down Expand Up @@ -1600,6 +1702,7 @@ - (void)useEmulatorApp:(nonnull AuthPigeonFirebaseApp *)app
completion:(nonnull void (^)(FlutterError *_Nullable))completion {
FIRAuth *auth = [self getFIRAuthFromAppNameFromPigeon:app];
[auth useEmulatorWithHost:host port:port];
_emulatorConfigs[app.appName] = @{@"host" : host, @"port" : @(port)};
completion(nil);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,59 @@ void main() {
fail(e.toString());
}
});

test('returns correct operation for verifyEmail action code',
() async {
final email = generateRandomEmail();
await FirebaseAuth.instance.createUserWithEmailAndPassword(
email: email,
password: testPassword,
);

await FirebaseAuth.instance.currentUser!.sendEmailVerification();

final oobCode = await emulatorOutOfBandCode(
email,
EmulatorOobCodeType.verifyEmail,
);
expect(oobCode, isNotNull);

final actionCodeInfo = await FirebaseAuth.instance.checkActionCode(
oobCode!.oobCode!,
);

expect(
actionCodeInfo.operation,
equals(ActionCodeInfoOperation.verifyEmail),
);
});

test('returns correct operation for passwordReset action code',
() async {
final email = generateRandomEmail();
await FirebaseAuth.instance.createUserWithEmailAndPassword(
email: email,
password: testPassword,
);
await ensureSignedOut();

await FirebaseAuth.instance.sendPasswordResetEmail(email: email);

final oobCode = await emulatorOutOfBandCode(
email,
EmulatorOobCodeType.passwordReset,
);
expect(oobCode, isNotNull);

final actionCodeInfo = await FirebaseAuth.instance.checkActionCode(
oobCode!.oobCode!,
);

expect(
actionCodeInfo.operation,
equals(ActionCodeInfoOperation.passwordReset),
);
});
},
skip: !kIsWeb && Platform.isWindows,
);
Expand Down
Loading