Skip to content

Commit a78cb64

Browse files
Merge pull request #1406 from firebase/auth-1391
fix(auth): ensure pending credential is rehydrated before linkWithCredential
2 parents 7e63766 + c004e69 commit a78cb64

3 files changed

Lines changed: 172 additions & 7 deletions

File tree

packages/core/src/auth.test.ts

Lines changed: 134 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ vi.mock("firebase/auth", () => ({
4848
PhoneAuthProvider: Object.assign(vi.fn(), {
4949
credential: vi.fn(),
5050
}),
51+
OAuthProvider: {
52+
credentialFromJSON: vi.fn(),
53+
},
54+
SAMLAuthProvider: {
55+
credentialFromJSON: vi.fn(),
56+
},
5157
linkWithCredential: vi.fn(),
5258
}));
5359

@@ -64,6 +70,9 @@ import {
6470
signInWithCredential as _signInWithCredential,
6571
EmailAuthProvider,
6672
PhoneAuthProvider,
73+
OAuthProvider,
74+
SAMLAuthProvider,
75+
linkWithCredential as _linkWithCredential,
6776
createUserWithEmailAndPassword as _createUserWithEmailAndPassword,
6877
sendPasswordResetEmail as _sendPasswordResetEmail,
6978
sendSignInLinkToEmail as _sendSignInLinkToEmail,
@@ -81,8 +90,6 @@ import { FirebaseError } from "firebase/app";
8190

8291
import { createMockUI } from "~/tests/utils";
8392

84-
// TODO(ehesp): Add tests for handlePendingCredential.
85-
8693
describe("signInWithEmailAndPassword", () => {
8794
beforeEach(() => {
8895
vi.clearAllMocks();
@@ -1007,6 +1014,131 @@ describe("signInAnonymously", () => {
10071014
});
10081015
});
10091016

1017+
describe("handlePendingCredential", () => {
1018+
beforeEach(() => {
1019+
vi.clearAllMocks();
1020+
window.sessionStorage.clear();
1021+
});
1022+
1023+
afterEach(() => {
1024+
window.sessionStorage.clear();
1025+
});
1026+
1027+
const mockUserCredential = {
1028+
user: { uid: "anonymous-uid" },
1029+
providerId: "anonymous",
1030+
operationType: "signIn",
1031+
} as UserCredential;
1032+
1033+
it("should return the user unchanged when there is no pending credential", async () => {
1034+
const mockUI = createMockUI();
1035+
vi.mocked(_signInAnonymously).mockResolvedValue(mockUserCredential);
1036+
1037+
const result = await signInAnonymously(mockUI);
1038+
1039+
expect(OAuthProvider.credentialFromJSON).not.toHaveBeenCalled();
1040+
expect(_linkWithCredential).not.toHaveBeenCalled();
1041+
expect(result).toBe(mockUserCredential);
1042+
});
1043+
1044+
it("should rehydrate an OAuth credential via OAuthProvider.credentialFromJSON and link it", async () => {
1045+
const mockUI = createMockUI();
1046+
const storedJSON = { providerId: "google.com", signInMethod: "google.com", idToken: "fake-id-token" };
1047+
window.sessionStorage.setItem("pendingCred", JSON.stringify(storedJSON));
1048+
1049+
const rehydratedCredential = { providerId: "google.com" } as any;
1050+
const linkedUserCredential = { ...mockUserCredential, providerId: "google.com" } as UserCredential;
1051+
1052+
vi.mocked(_signInAnonymously).mockResolvedValue(mockUserCredential);
1053+
vi.mocked(OAuthProvider.credentialFromJSON).mockReturnValue(rehydratedCredential);
1054+
vi.mocked(_linkWithCredential).mockResolvedValue(linkedUserCredential);
1055+
1056+
const result = await signInAnonymously(mockUI);
1057+
1058+
expect(OAuthProvider.credentialFromJSON).toHaveBeenCalledWith(storedJSON);
1059+
expect(_linkWithCredential).toHaveBeenCalledWith(mockUserCredential.user, rehydratedCredential);
1060+
expect(result).toBe(linkedUserCredential);
1061+
expect(window.sessionStorage.getItem("pendingCred")).toBeNull();
1062+
});
1063+
1064+
it("should fall back to SAMLAuthProvider when OAuthProvider cannot rehydrate the credential", async () => {
1065+
const mockUI = createMockUI();
1066+
const storedJSON = { providerId: "saml.my-provider", signInMethod: "saml.my-provider", pendingToken: "abc" };
1067+
window.sessionStorage.setItem("pendingCred", JSON.stringify(storedJSON));
1068+
1069+
const rehydratedCredential = { providerId: "saml.my-provider" } as any;
1070+
const linkedUserCredential = { ...mockUserCredential, providerId: "saml.my-provider" } as UserCredential;
1071+
1072+
vi.mocked(_signInAnonymously).mockResolvedValue(mockUserCredential);
1073+
vi.mocked(OAuthProvider.credentialFromJSON).mockImplementation(() => {
1074+
throw new Error("not an OAuth credential");
1075+
});
1076+
vi.mocked(SAMLAuthProvider.credentialFromJSON).mockReturnValue(rehydratedCredential);
1077+
vi.mocked(_linkWithCredential).mockResolvedValue(linkedUserCredential);
1078+
1079+
const result = await signInAnonymously(mockUI);
1080+
1081+
expect(SAMLAuthProvider.credentialFromJSON).toHaveBeenCalledWith(storedJSON);
1082+
expect(_linkWithCredential).toHaveBeenCalledWith(mockUserCredential.user, rehydratedCredential);
1083+
expect(result).toBe(linkedUserCredential);
1084+
expect(window.sessionStorage.getItem("pendingCred")).toBeNull();
1085+
});
1086+
1087+
it("should return the original user and clear storage when the credential cannot be rehydrated", async () => {
1088+
const mockUI = createMockUI();
1089+
const storedJSON = { providerId: "unknown", signInMethod: "unknown" };
1090+
window.sessionStorage.setItem("pendingCred", JSON.stringify(storedJSON));
1091+
1092+
vi.mocked(_signInAnonymously).mockResolvedValue(mockUserCredential);
1093+
vi.mocked(OAuthProvider.credentialFromJSON).mockImplementation(() => {
1094+
throw new Error("not an OAuth credential");
1095+
});
1096+
vi.mocked(SAMLAuthProvider.credentialFromJSON).mockImplementation(() => {
1097+
throw new Error("not a SAML credential");
1098+
});
1099+
1100+
const result = await signInAnonymously(mockUI);
1101+
1102+
expect(_linkWithCredential).not.toHaveBeenCalled();
1103+
expect(result).toBe(mockUserCredential);
1104+
expect(window.sessionStorage.getItem("pendingCred")).toBeNull();
1105+
});
1106+
1107+
it("should return the original user and clear storage when the stored credential is invalid JSON", async () => {
1108+
const mockUI = createMockUI();
1109+
window.sessionStorage.setItem("pendingCred", "{invalid-json");
1110+
1111+
vi.mocked(_signInAnonymously).mockResolvedValue(mockUserCredential);
1112+
1113+
const result = await signInAnonymously(mockUI);
1114+
1115+
expect(OAuthProvider.credentialFromJSON).not.toHaveBeenCalled();
1116+
expect(_linkWithCredential).not.toHaveBeenCalled();
1117+
expect(result).toBe(mockUserCredential);
1118+
expect(window.sessionStorage.getItem("pendingCred")).toBeNull();
1119+
});
1120+
1121+
it("should return the original user and clear storage when linkWithCredential fails", async () => {
1122+
const mockUI = createMockUI();
1123+
const storedJSON = { providerId: "google.com", signInMethod: "google.com", idToken: "fake-id-token" };
1124+
window.sessionStorage.setItem("pendingCred", JSON.stringify(storedJSON));
1125+
1126+
const rehydratedCredential = { providerId: "google.com" } as any;
1127+
1128+
vi.mocked(_signInAnonymously).mockResolvedValue(mockUserCredential);
1129+
vi.mocked(OAuthProvider.credentialFromJSON).mockReturnValue(rehydratedCredential);
1130+
vi.mocked(_linkWithCredential).mockRejectedValue(
1131+
new FirebaseError("auth/credential-already-in-use", "Credential already in use")
1132+
);
1133+
1134+
const result = await signInAnonymously(mockUI);
1135+
1136+
expect(_linkWithCredential).toHaveBeenCalledWith(mockUserCredential.user, rehydratedCredential);
1137+
expect(result).toBe(mockUserCredential);
1138+
expect(window.sessionStorage.getItem("pendingCred")).toBeNull();
1139+
});
1140+
});
1141+
10101142
describe("signInWithCustomToken", () => {
10111143
beforeEach(() => {
10121144
vi.clearAllMocks();

packages/core/src/auth.ts

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ import {
2424
signInWithCustomToken as _signInWithCustomToken,
2525
EmailAuthProvider,
2626
linkWithCredential,
27+
OAuthProvider,
2728
PhoneAuthProvider,
29+
SAMLAuthProvider,
2830
TotpMultiFactorGenerator,
2931
multiFactor,
3032
type ActionCodeSettings,
@@ -44,17 +46,47 @@ import { hasBehavior, getBehavior } from "./behaviors/index";
4446
import { FirebaseError } from "firebase/app";
4547
import { getTranslation } from "./translations";
4648

49+
/**
50+
* Rehydrates a plain object (produced by `AuthCredential.toJSON()` and round-tripped
51+
* through `JSON.stringify`/`JSON.parse`) back into a real `AuthCredential` instance.
52+
*
53+
* `linkWithCredential` requires an actual `AuthCredential` - a plain object lacks the
54+
* internal methods (e.g. `_linkToIdToken`) that Firebase Auth relies on, so it must be
55+
* rehydrated via the relevant provider's `credentialFromJSON` before use.
56+
*
57+
* @param json - The parsed JSON representation of the credential.
58+
* @returns The rehydrated `AuthCredential`, or `null` if it could not be rehydrated.
59+
*/
60+
function credentialFromJSON(json: unknown): AuthCredential | null {
61+
if (typeof json !== "object" || json === null) {
62+
return null;
63+
}
64+
65+
try {
66+
return OAuthProvider.credentialFromJSON(json);
67+
} catch {
68+
// Not an OAuth credential - fall through and try other providers below.
69+
}
70+
71+
try {
72+
return SAMLAuthProvider.credentialFromJSON(json);
73+
} catch {
74+
return null;
75+
}
76+
}
77+
4778
async function handlePendingCredential(_ui: FirebaseUI, user: UserCredential): Promise<UserCredential> {
4879
const pendingCredString = window.sessionStorage.getItem("pendingCred");
4980
if (!pendingCredString) return user;
5081

82+
window.sessionStorage.removeItem("pendingCred");
83+
5184
try {
52-
const pendingCred = JSON.parse(pendingCredString);
53-
const result = await linkWithCredential(user.user, pendingCred);
54-
window.sessionStorage.removeItem("pendingCred");
55-
return result;
85+
const pendingCred = credentialFromJSON(JSON.parse(pendingCredString));
86+
if (!pendingCred) return user;
87+
88+
return await linkWithCredential(user.user, pendingCred);
5689
} catch {
57-
window.sessionStorage.removeItem("pendingCred");
5890
return user;
5991
}
6092
}

packages/core/src/errors.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export function handleFirebaseError(ui: FirebaseUI, error: unknown): never {
5252

5353
// TODO(ehesp): Type error as unknown, check instance of FirebaseError
5454
// TODO(ehesp): Support via behavior
55+
// Note: the credential is stored via `toJSON()`; it must be rehydrated (see handlePendingCredential in auth.ts) before being passed to linkWithCredential.
5556
if (error.code === "auth/account-exists-with-different-credential" && errorContainsCredential(error)) {
5657
window.sessionStorage.setItem("pendingCred", JSON.stringify(error.credential.toJSON()));
5758
}

0 commit comments

Comments
 (0)